From 5d21ca879126e8c27d5f757d6b834b67b17cbb5c Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 12:51:52 -0500 Subject: [PATCH 1/2] Minimize: a click that shows the right sidebar no longer moves the frame it selects (#2051) The view keeps its centre when the canvas is resized, so the right panel appearing (minimized UI, on the press that selects) shifted the pointer's page point by half the width over the zoom and the move handling took it as a drag. While a button that went down on the canvas is held, a resize now keeps the top-left instead. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/canvas.rs | 11 +++ crates/ui-egui/src/panel_width_tests.rs | 96 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ccfe5d46..e1e78b53d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -206,8 +206,19 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { { // Keep the centre; the first sizing after opening re-fits. let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0; + // While a button is held that went down on the canvas (a click that selects shows the right panel, minimized, and a + // click that deselects hides it) the page point under the pointer stays where it is: the + // gesture's own pointer events would otherwise move by half the panel's width over the + // zoom, and the move handling would take that for a drag. Keep the top-left instead. + let held = resp.is_pointer_button_down_on() && !first; + let z = ds.view.zoom.max(1e-9); + let (dw, dh) = (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh); ds.view.vw = f64::from(rect.width()); ds.view.vh = f64::from(rect.height()); + if held { + ds.view.cx += dw / 2.0 / z; + ds.view.cy += dh / 2.0 / z; + } if first { ds.zoom_to_fit(); } diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index 357b13669..c7453be24 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -346,3 +346,99 @@ fn a_narrow_window_shows_one_side_panel_at_a_time() { let wide = canvas_width(&mut app, vec2(1440.0, 900.0)); assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})"); } + +/// Five frames side by side at 46% zoom with the interface minimized and nothing selected. +fn minimized_row() -> (Ui, Vec) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for k in 0..5 { + let r = s.execute("frame.create", &json!({"x": k * 470, "y": 0, "width": 400, "height": 600})).unwrap(); + ids.push(figcraft_engine::params::id_of(&r["id"]).unwrap()); + } + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut u = Ui::new(s); + u.app.run("view.minimize", json!({"on": true})); + u.app.run("view.set", json!({"zoom": 0.46, "cx": 1500, "cy": 300})); + for _ in 0..4 { + u.frame(vec![]); + } + (u, ids) +} + +fn frame_x(u: &Ui, id: figcraft_doc::NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(id).unwrap().x0 +} + +/// A click on a frame while minimized selects it and shows the right panel (the canvas gets +/// narrower): the frame stays where it was, and no step is added to the history. +#[test] +fn a_click_that_shows_the_right_panel_does_not_move_the_frame() { + let (mut u, ids) = minimized_row(); + assert!(u.app.ui.right_panel_rect.is_none(), "nothing selected: no right panel"); + let wide = u.app.canvas.rect.width(); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..4 { + u.frame(vec![]); + } + assert_eq!(u.app.session.selection(), vec![ids[4]], "the click selects the frame"); + assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows"); + assert!(u.app.canvas.rect.width() < wide - 100.0, "the canvas is narrower"); + assert_eq!(frame_x(&u, ids[4]), 1880.0, "a click did not move the frame"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step"); +} + +/// A real drag still moves the frame, by the pointer's screen movement over the zoom, in one step. +#[test] +fn a_drag_after_the_right_panel_shows_moves_the_frame_by_the_pointer() { + let (mut u, ids) = minimized_row(); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![Event::PointerMoved(p + vec2(50.0, 0.0))]); + u.frame(vec![Event::PointerMoved(p + vec2(100.0, 0.0))]); + u.frame(vec![Event::PointerButton { + pos: p + vec2(100.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows"); + let moved = frame_x(&u, ids[4]) - 1880.0; + assert!((moved - 100.0 / 0.46).abs() < 6.0, "moved by 100 points over the zoom, got {moved}"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one step"); +} + +/// Clicking empty canvas deselects and hides the right panel again; the marquee that starts there +/// has no offset: nothing moves and nothing is selected. +#[test] +fn a_click_that_hides_the_right_panel_does_not_move_the_frame() { + let (mut u, ids) = minimized_row(); + u.app.run("select.set", json!({"ids": [ids[4]]})); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_some()); + let p = u.app.to_screen(1880.0 + 200.0, 1000.0); + let steps = u.app.session.ds().unwrap().history.undo.len(); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_none(), "deselected: no right panel"); + assert_eq!(frame_x(&u, ids[4]), 1880.0); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps); +} From 36b728304b11090583184bc0021f9f1744c3dcb4 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 13:43:32 -0500 Subject: [PATCH 2/2] Minimize: a held press keeps its page point when the canvas moves, not only when it narrows (#2051) While a press that went down on the canvas is held, the view centre now follows the canvas rect's own move as well as its size change, so a still pointer keeps its page point when the left sidebar or rail appears mid-press. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/canvas.rs | 41 ++++++++++++++----------- crates/ui-egui/src/panel_width_tests.rs | 31 +++++++++++++++++++ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1e78b53d..684294c44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -195,32 +195,37 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers /// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); - app.canvas.rect = rect; + let old_rect = std::mem::replace(&mut app.canvas.rect, rect); let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag()); let key_box = app.ui.key_box; crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); - if let Ok(ds) = app.session.ds_mut() - && ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5) - { - // Keep the centre; the first sizing after opening re-fits. + if let Ok(ds) = app.session.ds_mut() { + let resized = (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5; + // The first sizing after opening re-fits. let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0; - // While a button is held that went down on the canvas (a click that selects shows the right panel, minimized, and a - // click that deselects hides it) the page point under the pointer stays where it is: the - // gesture's own pointer events would otherwise move by half the panel's width over the - // zoom, and the move handling would take that for a drag. Keep the top-left instead. - let held = resp.is_pointer_button_down_on() && !first; + // While a button is held that went down on the canvas (a click that selects shows the + // right panel, minimized, and a click that deselects hides it; the left panel and rail + // can appear too) the page point under a still pointer stays where it is: the gesture's + // own pointer events would otherwise map to another page point and the move handling + // would take that for a drag. `to_page` is `origin + (p - rect.min) / z` with + // `origin = c - vw / 2 / z`, so the centre moves by half the size change plus the + // rect's own move, over the zoom. + let held = resp.is_pointer_button_down_on() && !first && old_rect.is_finite(); let z = ds.view.zoom.max(1e-9); - let (dw, dh) = (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh); - ds.view.vw = f64::from(rect.width()); - ds.view.vh = f64::from(rect.height()); - if held { - ds.view.cx += dw / 2.0 / z; - ds.view.cy += dh / 2.0 / z; + let (dw, dh) = if resized { (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh) } else { (0.0, 0.0) }; + if resized { + // Otherwise keep the centre. + ds.view.vw = f64::from(rect.width()); + ds.view.vh = f64::from(rect.height()); + if first { + ds.zoom_to_fit(); + } } - if first { - ds.zoom_to_fit(); + if held { + ds.view.cx += (dw / 2.0 + f64::from(rect.min.x - old_rect.min.x)) / z; + ds.view.cy += (dh / 2.0 + f64::from(rect.min.y - old_rect.min.y)) / z; } } let own_cursor = input(app, ui, &resp); diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index c7453be24..1bc17e7a1 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -442,3 +442,34 @@ fn a_click_that_hides_the_right_panel_does_not_move_the_frame() { assert_eq!(frame_x(&u, ids[4]), 1880.0); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps); } + +/// The left sidebar and rail appearing while a press is held (Minimize toggled off mid-press) +/// moves the canvas's left edge: a still pointer keeps its page point, so the release moves nothing. +#[test] +fn a_held_press_keeps_its_page_point_when_the_canvas_moves() { + let (mut u, ids) = minimized_row(); + u.app.run("select.set", json!({"ids": [ids[3]]})); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = u.app.session.ds().unwrap().history.undo.len(); + let left = u.app.canvas.rect.min.x; + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + let before = u.app.to_page(p); + u.app.run("view.minimize", json!({"on": false})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.canvas.rect.min.x > left + 100.0, "the left sidebar and rail moved the canvas's edge"); + let after = u.app.to_page(p); + assert!((after.x - before.x).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "the pointer's page point moved: {before:?} to {after:?}"); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(frame_x(&u, ids[4]), 1880.0, "the frame did not move"); + assert_eq!(frame_x(&u, ids[3]), 1410.0); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step"); +}