From 75f54e0ae96cb6f9e0e2e481fee4b4d67d508a04 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 23:59:04 -0500 Subject: [PATCH 1/3] View: the arrow keys pan 64 px, 320 with Shift, as measured in Figma With nothing selected, one arrow press panned 20 px (200 with Shift); Figma pans about 64 px and five times that with Shift (measured at 100%, about +/-3 px). New engine test pins both steps; no existing test pinned the old values. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at low --- crates/engine/src/cmd/view.rs | 22 +++++++++++++++++++--- docs/ui-parity.md | 3 ++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c33878066..c58c31d94 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -100,12 +100,16 @@ fn zoom_to(s: &mut Session, z: f64) -> Result { Ok(json!({"zoom": v.zoom})) } -/// How far an arrow key pans the view with nothing selected (screen pixels; Shift: ten times). -pub const PAN_STEP_PX: f64 = 20.0; +/// How far an arrow key pans the view with nothing selected (screen pixels). Measured in Figma at +/// 100% zoom: about 64 px (62-64 in two trials, +/-3 px). +pub const PAN_STEP_PX: f64 = 64.0; + +/// Shift multiplies the arrow pan by this much (Figma: about 320 px, five steps). +pub const PAN_BIG_STEPS: f64 = 5.0; /// `view.pan`: the arrow keys with nothing selected move the view by keyboard steps. fn pan(s: &mut Session, p: &Value) -> Result { - let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * 10.0 } else { PAN_STEP_PX }; + let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * PAN_BIG_STEPS } else { PAN_STEP_PX }; let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step; let (dx, dy) = (dir("dx"), dir("dy")); let v = view(s)?; @@ -672,4 +676,16 @@ mod start_on_home_tests { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.start_on_home); } + + #[test] + fn the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma() { + let mut s = crate::Session::new(); + s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap(); + s.execute("view.pan", &json!({"dx": 1})).unwrap(); + let v = &s.ds().unwrap().view; + assert_eq!((v.cx, v.cy), (364.0, 200.0), "one press pans 64 px"); + s.execute("view.pan", &json!({"dy": -1, "big": true})).unwrap(); + let v = &s.ds().unwrap().view; + assert_eq!((v.cx, v.cy), (364.0, -120.0), "Shift pans 320 px"); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index aa0ef25bd..0564d7ce3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -109,7 +109,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | +| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-07 | patch | View: the arrow keys with nothing selected pan 64 px, 320 with Shift (five steps), as measured in Figma's web app at 100% zoom (62–64 px in two trials, about 320 px with Shift, about ±3 px); they panned 20 px (200 with Shift). Test: `cmd::view::start_on_home_tests::the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma` | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From d036eb2f9b4255f0c03531a1aee1bc2897fba179 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 00:42:50 -0500 Subject: [PATCH 2/3] Tests: the UI arrow-pan test at the new step; the step at other zooms and on both axes (#2106 review) Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at low --- crates/engine/src/cmd/view.rs | 29 ++++++++++++++++++++++ crates/ui-egui/src/inspect_fields_tests.rs | 6 ++--- crates/ui-egui/src/shortcut_keys_tests.rs | 6 ++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c58c31d94..0dfd4f28a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -688,4 +688,33 @@ mod start_on_home_tests { let v = &s.ds().unwrap().view; assert_eq!((v.cx, v.cy), (364.0, -120.0), "Shift pans 320 px"); } + + #[test] + fn the_arrow_pan_is_the_same_screen_distance_at_any_zoom_on_both_axes() { + // (zoom, dx, dy, big, expected centre change in document px) + let cases = [ + (2.0, 1.0, 0.0, false, 32.0, 0.0), + (0.5, 1.0, 0.0, false, 128.0, 0.0), + (2.0, 0.0, 1.0, true, 0.0, 160.0), + (0.5, 0.0, 1.0, true, 0.0, 640.0), + (1.0, -1.0, 0.0, false, -64.0, 0.0), + (1.0, 1.0, 0.0, true, 320.0, 0.0), + (1.0, -1.0, 0.0, true, -320.0, 0.0), + (1.0, 0.0, -1.0, false, 0.0, -64.0), + (1.0, 0.0, 1.0, false, 0.0, 64.0), + (1.0, 0.0, -1.0, true, 0.0, -320.0), + ]; + for (zoom, dx, dy, big, ex, ey) in cases { + let mut s = crate::Session::new(); + s.execute("view.set", &json!({"zoom": zoom, "cx": 300, "cy": 200})).unwrap(); + s.execute("view.pan", &json!({"dx": dx, "dy": dy, "big": big})).unwrap(); + let v = &s.ds().unwrap().view; + let got = (v.cx - 300.0, v.cy - 200.0); + assert!( + (got.0 - ex).abs() < 1e-6 && (got.1 - ey).abs() < 1e-6, + "zoom {zoom} dx {dx} dy {dy} big {big}: moved {got:?}, expected {:?}", + (ex, ey) + ); + } + } } diff --git a/crates/ui-egui/src/inspect_fields_tests.rs b/crates/ui-egui/src/inspect_fields_tests.rs index bdd0abcd8..9164f02b8 100644 --- a/crates/ui-egui/src/inspect_fields_tests.rs +++ b/crates/ui-egui/src/inspect_fields_tests.rs @@ -62,12 +62,12 @@ fn control_key_arrows_pan_with_nothing_selected() { (v.cx, v.cy) }; call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap(); - assert_eq!(view(&u), (10.0, 0.0), "20 screen pixels at 200%"); + assert_eq!(view(&u), (32.0, 0.0), "64 screen pixels at 200% are 32 document pixels"); call(&mut u, "ui.key", json!({"key": "ArrowUp", "shift": true})).unwrap(); - assert_eq!(view(&u), (10.0, -100.0), "Shift: ten times as far"); + assert_eq!(view(&u), (32.0, -160.0), "Shift: 320 screen pixels, 160 document pixels"); u.app.run("select.set", json!({"ids": [r]})); call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap(); - assert_eq!(view(&u), (10.0, -100.0), "with a selection the view stays"); + assert_eq!(view(&u), (32.0, -160.0), "with a selection the view stays"); let id = figcraft_engine::params::id_of(&r).unwrap(); assert_eq!(u.app.session.doc().unwrap().world_bounds(id).unwrap().x0, 1.0, "the selection moved instead"); // With a shape tool chosen and nothing selected the arrows move the placement crosshair diff --git a/crates/ui-egui/src/shortcut_keys_tests.rs b/crates/ui-egui/src/shortcut_keys_tests.rs index dbe6ab3b9..e48a3d0bb 100644 --- a/crates/ui-egui/src/shortcut_keys_tests.rs +++ b/crates/ui-egui/src/shortcut_keys_tests.rs @@ -44,13 +44,13 @@ fn arrows_pan_the_view_when_nothing_is_selected() { u.app.run("select.set", json!({"ids": []})); u.app.run("view.set", json!({"zoom": 2.0, "cx": 0, "cy": 0})); press(&mut u, Key::ArrowRight, Modifiers::NONE); - assert_eq!(view(&u), (10.0, 0.0, 2.0), "20 screen pixels at 200%"); + assert_eq!(view(&u), (32.0, 0.0, 2.0), "64 screen pixels at 200% are 32 document pixels"); press(&mut u, Key::ArrowUp, Modifiers::SHIFT); - assert_eq!(view(&u), (10.0, -100.0, 2.0), "Shift: ten times as far"); + assert_eq!(view(&u), (32.0, -160.0, 2.0), "Shift: 320 screen pixels, 160 document pixels"); // With a selection the arrows nudge it and the view stays. u.app.run("select.set", json!({"ids": [r]})); press(&mut u, Key::ArrowRight, Modifiers::NONE); - assert_eq!(view(&u), (10.0, -100.0, 2.0)); + assert_eq!(view(&u), (32.0, -160.0, 2.0)); assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0); } From 9f51517f235d873fec731c50709baeddc5f5572e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 12:39:56 -0500 Subject: [PATCH 3/3] M8: view.pan's parameter text and the MCP guide give the measured steps 64 screen pixels a step, five times as far with big (320), as the code and the measurement have it; they still said ten times and 20 / 200. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 2 +- docs/mcp.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 5717650af..5ef6aaba1 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -302,7 +302,7 @@ pub fn specs() -> Vec { spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { toggle(s, p, "view.multiplayerCursors") }), - spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), + spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (five times as far, like Shift)}", false, pan), spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { toggle(s, p, "view.outlinesSelection") diff --git a/docs/mcp.md b/docs/mcp.md index 04b37e1f0..b72e71180 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -622,7 +622,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file shortcut uses; on a design file they refuse. - `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same) - pan the view by 20 screen pixels a step, 200 with `big` (Shift). + pan the view by 64 screen pixels a step, 320 with `big` (Shift). - `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session. - `view.outlinesHiddenLayers {on?}` and `view.outlinesObjectBounds {on?}`: what outline mode (`view.outlines`, `view.outlinesSelection`) draws besides the shapes: hidden layers, and the box