From 6fce958ae68a6d04fdf8ab05a3822b97173dbf6f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 9 Nov 2026 02:53:44 -0500 Subject: [PATCH 1/3] M8: Preferences > Invert zoom direction reverses wheel zoom (#2145) Adds the invert_zoom preference and the pref.invertZoom command, a checkbox in the Preferences window after Scroll wheel zoom. With it on, Cmd/Ctrl+wheel and a plain mouse wheel (with Scroll wheel zoom on) zoom the other way by the same factor; a pinch and panning are unchanged. Documented in docs/ui-parity.md. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium --- crates/engine/src/a11y_keys_tests.rs | 16 ++++++ crates/engine/src/cmd/view.rs | 7 +++ crates/engine/src/prefs.rs | 4 ++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/canvas/input.rs | 12 ++++- crates/ui-egui/src/canvas/wheel_zoom_tests.rs | 50 ++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 1 + docs/ui-parity.md | 5 +- 8 files changed, 91 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index e4892bcae..5dd604670 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -103,3 +103,19 @@ fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_s let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); } + +#[test] +fn the_invert_zoom_preference_toggles_and_is_saved() { + let mut s = Session::new(); + assert!(!s.prefs.invert_zoom, "off by default"); + assert_eq!(s.execute("pref.invertZoom", &json!({})).unwrap()["on"], true); + assert!(s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], true); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(loaded.invert_zoom); + assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false); + // Preferences saved before the setting existed read it as off. + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.invert_zoom); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 46ca701ff..2f2ec3e1f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -410,6 +410,13 @@ pub fn specs() -> Vec { false, |s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom) ), + spec!( + "pref.invertZoom", + "Invert zoom direction", + "{on?} reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in (a trackpad pinch and panning are unchanged) → {on}", + false, + |s, p| pref(s, p, |x| &mut x.invert_zoom) + ), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!( "pref.uiScale", diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 48d68a901..4225963f5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,9 @@ pub struct Prefs { /// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the /// other way round). Trackpads are unchanged. pub scroll_wheel_zoom: bool, + /// Wheel zoom goes the other way: Cmd/Ctrl+wheel (and a plain mouse wheel with Scroll wheel + /// zoom on) zooms out where it would zoom in. A trackpad pinch is unchanged. + pub invert_zoom: bool, /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its @@ -136,6 +139,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, scroll_wheel_zoom: false, + invert_zoom: false, theme: "system".into(), panel_widths: (240.0, 240.0), pages_height: None, diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 625ee3bd8..f3ce19403 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -882,6 +882,7 @@ name = "English" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}" "Remove these results from the file" = "Remove these results from the file" "Scroll wheel zoom" = "Scroll wheel zoom" +"Invert zoom direction" = "Invert zoom direction" "Shared by your team on {server}" = "Shared by your team on {server}" "Slot name" = "Slot name" "Mar" = "Mar" diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5187fe866..b4cff10aa 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -81,10 +81,18 @@ fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> if let Some(mouse) = latest { app.canvas.wheel_is_mouse = mouse; } + let invert = app.session.prefs.invert_zoom && !pinch; if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch { - return (wheel, zoom); + return (wheel, if invert { invert_zoom_factor(zoom) } else { zoom }); } - swap_wheel(wheel, zoom, shift, speed) + let (scroll, zoom) = swap_wheel(wheel, zoom, shift, speed); + (scroll, if invert { invert_zoom_factor(zoom) } else { zoom }) +} + +/// The zoom factor for the opposite direction (Preferences ▸ Invert zoom direction): its +/// reciprocal. A factor that is zero, negative or not finite is left as it is. +pub(crate) fn invert_zoom_factor(zoom: f32) -> f32 { + if zoom.is_finite() && zoom > 0.0 { 1.0 / zoom } else { zoom } } /// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs index 5b196a4d4..7574e96a4 100644 --- a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -6,7 +6,7 @@ use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, R use figcraft_engine::Session; use serde_json::json; -use super::input::{is_mouse_wheel, swap_wheel}; +use super::input::{invert_zoom_factor, is_mouse_wheel, swap_wheel}; use crate::{FigApp, NoServices}; /// The view's zoom, cx and cy. @@ -25,8 +25,13 @@ fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event { impl Canvas { fn new(scroll_wheel_zoom: bool) -> Self { + Self::with_invert(scroll_wheel_zoom, false) + } + + fn with_invert(scroll_wheel_zoom: bool, invert_zoom: bool) -> Self { let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 }; c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom})); + c.app.run("pref.invertZoom", json!({"on": invert_zoom})); for _ in 0..3 { c.frame(vec![], Modifiers::NONE); } @@ -126,3 +131,46 @@ fn the_swap_keeps_egui_rates() { assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0)); assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0)); } + +#[test] +fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() { + let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); +} + +#[test] +fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() { + let (mut off, mut on) = (Canvas::with_invert(true, false), Canvas::with_invert(true, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); + // Without Scroll wheel zoom a plain wheel still scrolls, inverted or not. + let mut plain = Canvas::with_invert(false, true); + let (a, b, _) = plain.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); + assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0, "scrolls: {a:?} -> {b:?}"); +} + +#[test] +fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() { + for invert in [false, true] { + let mut c = Canvas::with_invert(false, invert); + let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE); + assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "invert {invert}: {a:?} -> {b:?}"); + } +} + +#[test] +fn the_inverted_factor_is_the_reciprocal_and_guards_bad_values() { + assert!((invert_zoom_factor(2.0) - 0.5).abs() < 1e-6); + assert_eq!(invert_zoom_factor(1.0), 1.0); + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + let r = invert_zoom_factor(bad); + assert!(r == bad || (r.is_nan() && bad.is_nan()), "{bad} left alone"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 52170af44..e60928fd1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), (tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"), + (tr("Invert zoom direction"), prefs.invert_zoom, "pref.invertZoom"), (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..6984cf8b0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; 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, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** patch (Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; 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, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -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); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); 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-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. | | 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 1ef0c16fdf81b1488d458fc4dc158c20d65ce6d8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 9 Nov 2026 15:11:14 -0500 Subject: [PATCH 2/3] M8: invert zoom direction tests: Ctrl wheel, panning unchanged, pinch both ways, off persists (#2147 review) Tests only. Ctrl+wheel joins Cmd+wheel; panning (plain wheel, Cmd/Ctrl+wheel and Shift+wheel with Scroll wheel zoom on) is compared between invert on and off; the pinch runs with Scroll wheel zoom on and off and with factors above and below 1; the pref's off value is saved and reloaded. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 triaged and briefed; Sonnet 5.5 verified and wrote at medium --- crates/engine/src/a11y_keys_tests.rs | 5 +++ crates/ui-egui/src/canvas/wheel_zoom_tests.rs | 45 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 5dd604670..6fc5f2396 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -115,6 +115,11 @@ fn the_invert_zoom_preference_toggles_and_is_saved() { let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); assert!(loaded.invert_zoom); assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false); + assert!(!s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], false); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(!loaded.invert_zoom, "off persists after being on"); // Preferences saved before the setting existed read it as off. let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.invert_zoom); diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs index 7574e96a4..b5a36a7de 100644 --- a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -134,12 +134,14 @@ fn the_swap_keeps_egui_rates() { #[test] fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() { - let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); - let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); - let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); - assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); - assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); - assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); + for mods in [Modifiers::COMMAND, Modifiers::CTRL] { + let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + assert!(b.0 > a.0 * 1.05, "{mods:?} off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "{mods:?} on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "{mods:?} by the same factor"); + } } #[test] @@ -150,18 +152,33 @@ fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() { assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); - // Without Scroll wheel zoom a plain wheel still scrolls, inverted or not. - let mut plain = Canvas::with_invert(false, true); - let (a, b, _) = plain.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); - assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0, "scrolls: {a:?} -> {b:?}"); +} + +/// Panning is never inverted: the scroll vector is the same with the preference on and off. +#[test] +fn invert_zoom_leaves_panning_unchanged() { + // (Scroll wheel zoom, modifiers): a plain wheel without it, Cmd/Ctrl+wheel with it, Shift+wheel. + for (swz, mods) in [(false, Modifiers::NONE), (true, Modifiers::COMMAND), (true, Modifiers::CTRL), (true, Modifiers::SHIFT)] { + let (mut off, mut on) = (Canvas::with_invert(swz, false), Canvas::with_invert(swz, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + assert!((b.0 - a.0).abs() < 1e-9 && (d.0 - c.0).abs() < 1e-9, "{mods:?} no zoom"); + assert!(b.1 != a.1 || b.2 != a.2, "{mods:?} off pans: {a:?} -> {b:?}"); + assert!( + ((b.1 - a.1) - (d.1 - c.1)).abs() < 1e-9 && ((b.2 - a.2) - (d.2 - c.2)).abs() < 1e-9, + "{mods:?} same pan: {a:?} -> {b:?} vs {c:?} -> {d:?}" + ); + } } #[test] fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() { - for invert in [false, true] { - let mut c = Canvas::with_invert(false, invert); - let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE); - assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "invert {invert}: {a:?} -> {b:?}"); + for (swz, invert, factor) in + [(false, false, 1.5), (false, true, 1.5), (true, false, 1.5), (true, true, 1.5), (true, true, 0.5), (false, true, 0.5)] + { + let mut c = Canvas::with_invert(swz, invert); + let (a, b, _) = c.gesture(Event::Zoom(factor), Modifiers::NONE); + assert!((b.0 / a.0 - f64::from(factor)).abs() < 1e-3, "scroll wheel zoom {swz}, invert {invert}, x{factor}: {a:?} -> {b:?}"); } } From a69b27fd1e66af3f3bfffbc73f82bc4758f76428 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 12 Nov 2026 09:16:53 +0700 Subject: [PATCH 3/3] Spanish labels for Invert zoom direction --- crates/ui-egui/locales/es.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 258a1527d..1dfdc7076 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" "Create from color" = "Crear a partir del color" +"Invert zoom direction" = "Invertir la dirección del zoom" "New collection" = "Nueva colección" "Delete results" = "Eliminar resultados" "Pasted design layers" = "Capas de diseño pegadas" @@ -2214,6 +2215,7 @@ status = "draft — needs native review" "plugins.runLast" = "Ejecutar el último plugin" "pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" +"pref.invertZoom" = "Invertir la dirección del zoom" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" "pref.nudge" = "Cantidad de desplazamiento"