From a33d6a6ec8eaa03a0d49628437b34d6c66b04cbc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:45:26 +0700 Subject: [PATCH] Canvas: a General option makes Cmd/Ctrl-wheel scroll the canvas sideways Squashed from #5023 onto main. --- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 3 + crates/engine/src/tests/prefs.rs | 14 ++ crates/ui-egui/src/canvas/input.rs | 10 +- crates/ui-egui/src/canvas/tests.rs | 290 ++++++++++++++++++++++- crates/ui-egui/src/canvas/view.rs | 93 +++++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs_dialog.rs | 5 + crates/ui-egui/src/prefs_dialog/tests.rs | 35 +++ docs/control-protocol.md | 2 +- docs/mcp.md | 6 + docs/target-app-parity.md | 1 + 37 files changed, 472 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a702f1580..ad52c808a 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -150,6 +150,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("scaleCorners", "General", "Options", "Scale Corners", bool), p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool), p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), + p!("ctrlWheelScrollsSideways", "General", "Options", "Scroll Sideways with Cmd/Ctrl + Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 82946ac29..920ef3d1c 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -54,6 +54,8 @@ pub struct Prefs { /// Scale Strokes & Effects. pub scale_strokes: bool, pub zoom_with_mouse_wheel: bool, + /// While Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls sideways instead of zooming. + pub ctrl_wheel_scrolls_sideways: bool, /// A horizontal drag on a numeric field or its label steps its value (#400). pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. @@ -367,6 +369,7 @@ impl Default for Prefs { scale_corners: false, scale_strokes: false, zoom_with_mouse_wheel: false, + ctrl_wheel_scrolls_sideways: true, scrub_numeric_fields: true, trackpad_rotate_view: true, hold_tool_keys: true, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 437701136..7a17fe413 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -2511,6 +2511,20 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() { } } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel starts on: in a new session, in a preferences +/// file saved before the option existed and after a reset. +#[test] +fn scroll_sideways_with_cmd_wheel_is_on_by_default() { + assert!(Prefs::default().ctrl_wheel_scrolls_sideways); + let old: Prefs = serde_json::from_value(json!({"zoomWithMouseWheel": false})).unwrap(); + assert!(old.ctrl_wheel_scrolls_sideways); + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(!s.prefs.ctrl_wheel_scrolls_sideways); + s.execute("prefs.reset", &json!({})).unwrap(); + assert!(s.prefs.ctrl_wheel_scrolls_sideways); +} + /// The Transform panel menu's choice starts as `default` (the panels follow General › Transform /// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and /// after a reset; `prefs.set` takes it and the three menu choices, and nothing else. diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 49baf494b..0094dc07d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -206,8 +206,14 @@ fn wheel_view( let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, animate, line, rect.height(), &mut turn))); + let (pointer, m, space, (factor, scroll)) = ui.input(|i| { + ( + i.pointer.clone(), + i.modifiers, + i.key_down(egui::Key::Space), + wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn), + ) + }); if turn != turn_before { ui.data_mut(|d| d.insert_temp(turn_id, turn)); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d9fa7f09c..c37d6b868 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4306,7 +4306,8 @@ fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { } /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, -/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel +/// the wheel scrolls and Cmd-wheel scrolls sideways (Scroll Sideways with Cmd/Ctrl + Wheel is on +/// by default); on, the wheel zooms about the pointer, Shift-wheel /// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a /// trackpad pinch zoom about the pointer either way. #[test] @@ -4348,7 +4349,9 @@ fn zoom_with_mouse_wheel_preference() { let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; let (zoom, scroll, _) = turn(&mut app, ""); assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); - assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + // General › Scroll Sideways with Cmd/Ctrl + Wheel is on by default. + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer"); assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer"); app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); @@ -4361,6 +4364,289 @@ fn zoom_with_mouse_wheel_preference() { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default), through the control channel's +/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the +/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch +/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain +/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl +/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that +/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with +/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms. +#[test] +fn cmd_wheel_scrolls_sideways_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A canvas frame through the host's input hook (it feeds the control channel's input). + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + // One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how + // far the point under `at` moved). + let turn = |app: &mut VectorcraftApp, mods: &str| { + let before = *app.view().unwrap(); + if mods == "pinch" { + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + } else { + let mut p = json!({"x": at.x, "y": at.y, "dy": 1}); + for m in mods.split('+').filter(|m| !m.is_empty()) { + p[m] = json!(true); + } + crate::tests_synthetic::control(app, &ctx, "ui.wheel", p); + } + // egui spreads a notch over a few frames. + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; + assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": true})).unwrap(); + // A notch scrolls one wheel line. + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0, + "on: Cmd-wheel scrolls sideways by a notch ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "pinch")), "on: a pinch right after it zooms in about the pointer"); + // Two fingers spread on a touch screen, with no wheel turn since the Cmd-wheel one. + let before = app.view().unwrap().zoom; + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let fingers = |phase, d: f32| vec![touch(1, phase, at - vec2(40.0 + d, 0.0)), touch(2, phase, at + vec2(40.0 + d, 0.0))]; + frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, 0.0)); + for k in 1..=3 { + frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, 10.0 * k as f32)); + } + frame(&mut app, &ctx, fingers(egui::TouchPhase::End, 30.0)); + let zoom = app.view().unwrap().zoom / before; + assert!(zoom > 1.01, "on: fingers spread on a touch screen zoom in ({zoom})"); + assert!(zooms_in(turn(&mut app, "alt+cmd")), "on: Alt + Cmd-wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel"); + let (zoom, scroll, _) = turn(&mut app, ""); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: the wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + // A Cmd-wheel notch with a plain one right after it, before egui has spread the first: they + // scroll two notches in all, sideways and up. + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + scroll.x < -1.0 && scroll.y < -1.0 && (scroll.x + scroll.y + 2.0 * line).abs() < 0.5, + "on: a Cmd-wheel notch and a plain one right after it scroll two notches in all ({scroll:?})" + ); + // A pinch while egui still spreads a Cmd-wheel notch zooms by the pinch alone, and the + // canvas scrolls sideways by one notch; the pinch magnifies the part scrolled before it. + let before = *app.view().unwrap(); + let p = Xf::new(rect, &before).to_doc(at); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let (zoom, moved) = (after.zoom / before.zoom, Xf::new(rect, &after).to_screen(p) - at); + let notch = line as f32; + assert!( + (zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3, + "on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})" + ); + // A trackpad gesture starts with no modifier, and Cmd is pressed and released during it: + // egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling + // sideways until then. + let before = *app.view().unwrap(); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, egui::Modifiers::NONE), + swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND), + swipe(egui::TouchPhase::Move, egui::Modifiers::NONE), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0, + "on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", + after.zoom / before.zoom + ); + // An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui + // ends it before the next event, so a move with Cmd alone scrolls sideways. + // Frames at the given times, with the events in the frame's own input (the host's input + // hook hands synthetic events out over several frames). + let run_at = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + // The Alt part rests the fingers without moving, so only the Cmd move below acts. + let rest = |phase| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::ZERO, + phase, + modifiers: egui::Modifiers::ALT, + source: egui::MouseWheelSource::Unknown, + }; + let t = ctx.input(|i| i.time) + 1.0; + run_at(&mut app, t, vec![egui::Event::PointerMoved(at), rest(egui::TouchPhase::Start), rest(egui::TouchPhase::Move)]); + let before = *app.view().unwrap(); + run_at(&mut app, t + 2.0, vec![swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND)]); + for k in 1..=40 { + run_at(&mut app, t + 2.0 + f64::from(k) / 60.0, vec![]); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 6.0).abs() < 0.01 && scroll.y == 0.0, + "on: after egui's timeout, a Cmd move scrolls sideways ({}, {scroll:?})", + after.zoom / before.zoom + ); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, + "with Zoom with Mouse Wheel: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer"); +} + +/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms +/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and +/// Cmd-wheel alone scrolls sideways. +#[test] +fn cmd_wheel_sideways_alt_cmd_zooms_without_animated_zoom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("ctrlWheelScrollsSideways", true), ("zoomWithMouseWheel", false), ("animatedZoom", false)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": true, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed * o.input_options.scroll_zoom_speed).exp()); + let zoom = after.zoom / before.zoom; + assert!((zoom - expected).abs() < 1e-3, "Alt + Cmd-wheel zooms in by one notch ({zoom}, expected {expected})"); + assert!(Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)) < 1e-6, "Alt + Cmd-wheel zooms about the pointer"); + + let before = after; + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "Cmd-wheel does not zoom"); + assert!((scroll.x + line).abs() < 0.5 && scroll.y == 0.0, "Cmd-wheel scrolls sideways ({scroll:?})"); +} + +/// With Zoom with Mouse Wheel off, a trackpad gesture that starts with Alt keeps zooming about the +/// pointer until it ends, also after Alt is let go, as egui keeps a modifier held during a gesture, +/// with Animated Zoom on and off. A gesture that gains Alt partway zooms from then on. +#[test] +fn alt_wheel_gesture_keeps_zooming_after_alt_is_let_go() { + for animate in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("zoomWithMouseWheel", false), ("animatedZoom", animate)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + // One gesture with the given modifiers on its Start and its two Moves → (zoom ratio, how far + // the point under `at` moved). + let gesture = |app: &mut VectorcraftApp, mods: [egui::Modifiers; 3]| { + let before = *app.view().unwrap(); + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, mods[0]), + swipe(egui::TouchPhase::Move, mods[1]), + swipe(egui::TouchPhase::Move, mods[2]), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + (after.zoom / before.zoom, Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at))) + }; + let alt = egui::Modifiers::ALT; + let (held, _) = gesture(&mut app, [alt, alt, alt]); + assert!(held > 1.01, "animate {animate}: an Alt gesture zooms in ({held})"); + let (zoom, moved) = gesture(&mut app, [alt, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: a move without Alt keeps the gesture zooming about the pointer ({zoom}, expected {held}; moved {moved})" + ); + let (zoom, moved) = gesture(&mut app, [egui::Modifiers::NONE, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: Alt pressed partway zooms the rest of the gesture about the pointer ({zoom}, expected {held}; moved {moved})" + ); + } +} + /// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a /// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. #[test] diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index c0e2a4bc0..39bc80545 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -52,8 +52,19 @@ const WHEEL_GLIDE: f64 = 0.05; /// What the wheel did across frames ([`wheel`]). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct WheelTurn { - /// The last wheel turn was an Alt-wheel one. + /// The last wheel turn was an Alt-wheel one: `held` had Alt without Cmd/Ctrl at its last Start + /// or Move. alt: bool, + /// The modifiers egui's wheel state holds, followed while Zoom with Mouse Wheel is off: each + /// wheel event's own, or, through a trackpad gesture, all those held since it began; none + /// while the wheel is idle. + held: egui::Modifiers, + /// A trackpad gesture is under way: egui has seen its Start phase and has not yet seen its End + /// or Cancel phase. + gesture: bool, + /// When the last wheel event came (`InputState::time`), and whether a trackpad sent it. + last: f64, + trackpad: bool, /// The zoom (natural log) wheel notches still have to glide in. pub(super) glide: f64, } @@ -342,13 +353,22 @@ struct ThumbDrag { /// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option -/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel -/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls +/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the +/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points /// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated /// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does /// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at /// once either way. -pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { +pub(super) fn wheel( + i: &egui::InputState, + prefs: &vectorcraft_engine::Prefs, + wheel_zooms: bool, + animate: bool, + line: f32, + page: f32, + turn: &mut WheelTurn, +) -> (f64, egui::Vec2) { // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); // A wheel event's motion in points. @@ -360,17 +380,72 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line if !wheel_zooms { // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. - if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) { - turn.alt = m.alt && !m.command; + // Each wheel event changes `turn.held` and `turn.gesture` as `WheelState::on_wheel_event` + // changes egui's wheel modifiers and status. Both are cleared while egui's wheel is idle. + // `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held + // Alt keeps zooming until it ends, also after Alt is let go. + for e in &i.events { + if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e { + // A wheel event that comes after egui's scroll timeout ends the turn first, as + // `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else + // 0.15 s), also when no frame ran in between. + let timeout = if turn.gesture && turn.trackpad { 1.0 } else { 0.150 }; + if i.time - turn.last > timeout { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + turn.last = i.time; + turn.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => { + turn.gesture = true; + turn.held = *modifiers; + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::Move => { + if turn.gesture { + turn.held |= *modifiers; + } else { + turn.held = *modifiers; + } + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + } + } + } + if !i.is_scrolling() { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + // Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without + // Alt, egui's zoom, less the pinches', is `exp(points × scroll_zoom_speed)`, at egui's + // default speed, which the app keeps. The canvas scrolls sideways by those points instead. + // A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the + // fingers' alone, and the canvas leaves it as it is. + if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() { + let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product(); + let wheel_zoom = zoom / pinch; + if wheel_zoom.is_finite() && wheel_zoom > 0.0 { + scroll.x += (wheel_zoom.ln() / f64::from(egui::InputOptions::default().scroll_zoom_speed)) as f32; + zoom = pinch; + } } - let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); if turn.alt && !animate { // Without Animated Zoom, Alt-wheel zooms by the wheel events themselves, on the frame - // they arrive; the rest of egui's spread scroll is dropped. + // they arrive; the rest of egui's spread scroll is dropped. Only Move events count, as + // `on_wheel_event` moves egui's scroll by Move events alone. let d: f32 = i .events .iter() - .filter_map(|e| if let egui::Event::MouseWheel { unit, delta, .. } = e { Some(points(unit, *delta)) } else { None }) + .filter_map(|e| match e { + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, .. } => Some(points(unit, *delta)), + _ => None, + }) .map(|d| d.x + d.y) .sum(); return (zoom * (f64::from(d) * WHEEL_ZOOM).exp(), pan); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d8ada61f0..92e94aa1e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3710,6 +3710,7 @@ digits Malayalam Малаялам Zoom to Selection Приближаване до селекцията Zoom Tool Инструмент „Мащаб“ Zoom with Mouse Wheel Мащабиране с колелцето на мишката + Scroll Sideways with Cmd/Ctrl + Wheel Хоризонтално превъртане с Cmd/Ctrl + колелцето Scrub Numeric Fields by Dragging Плъзгане на числени полета чрез влачене @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} файл в {folder} {note}. Показване на папката на пакета?|{n} файла в {folder} {note}. Показване на папката на пакета? @plural {n} linked file not found|{n} linked files not found {n} свързан файл не е намерен|{n} свързани файла не са намерени diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index bcd447280..2c365fa50 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3699,6 +3699,7 @@ theme Appearance Izgled Yards Jardi Zoom to Selection Zumiraj na odabir Zoom with Mouse Wheel Zumiraj točkićem miša + Scroll Sideways with Cmd/Ctrl + Wheel Pomjeraj vodoravno uz Cmd/Ctrl + točkić miša Scrub Numeric Fields by Dragging Mijenjaj numerička polja prevlačenjem Linux Display Server Linux server prikaza Print Preview Pregled štampanja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bb3c3a35a..f0c3542ad 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3728,6 +3728,7 @@ digits Malayalam Malaiàlam Scribble… Garabateig… Script / Brush Cal·ligràfica / Pinzell Scripts Scripts + Scroll Sideways with Cmd/Ctrl + Wheel Desplaça horitzontalment amb Cmd/Ctrl + roda del ratolí Scrub Numeric Fields by Dragging Canvia els camps numèrics arrossegant Search Cerca Search All Cerca a tot diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9991617c3..233257897 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3744,6 +3744,7 @@ theme Appearance Motiv Yards Yardy Zoom to Selection Zvětšit na výběr Zoom with Mouse Wheel Zvětšovat kolečkem myši + Scroll Sideways with Cmd/Ctrl + Wheel Posouvat vodorovně kolečkem s Cmd/Ctrl Scrub Numeric Fields by Dragging Měnit číselné hodnoty tažením Linux Display Server Zobrazovací server Linuxu Print Preview Náhled tisku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 874d196f0..94516cb64 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3472,6 +3472,7 @@ theme Appearance Udseende Yards Yards Zoom to Selection Zoom til markering Zoom with Mouse Wheel Zoom med musehjul + Scroll Sideways with Cmd/Ctrl + Wheel Rul vandret med Cmd/Ctrl + musehjul Scrub Numeric Fields by Dragging Juster numeriske felter ved at trække Linux Display Server Linux-visningsserver Print Preview Udskriftsvisning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 114657cc7..6dc11392e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3703,6 +3703,7 @@ effect Points Punkte Zoom to Selection Auf Auswahl zoomen Zoom Tool Zoom-Werkzeug Zoom with Mouse Wheel Mit Mausrad zoomen + Scroll Sideways with Cmd/Ctrl + Wheel Mit Cmd/Ctrl + Mausrad horizontal scrollen Scrub Numeric Fields by Dragging Zahlenfelder durch Ziehen ändern @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} Datei in {folder} {note}. Paketordner anzeigen?|{n} Dateien in {folder} {note}. Paketordner anzeigen? @plural {n} linked file not found|{n} linked files not found {n} verknüpfte Datei nicht gefunden|{n} verknüpfte Dateien nicht gefunden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 00dcc7044..267920a0f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4036,6 +4036,7 @@ digits Malayalam Μαλαγιάλαμ Zig Zag… Ζιγκ-ζαγκ… Zoom to Selection Ζουμ στην επιλογή Zoom with Mouse Wheel Ζουμ με τον τροχό του ποντικιού + Scroll Sideways with Cmd/Ctrl + Wheel Οριζόντια κύλιση με Cmd/Ctrl + τροχό του ποντικιού Scrub Numeric Fields by Dragging Ρύθμιση αριθμητικών πεδίων με σύρσιμο @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} αρχείο στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας;|{n} αρχεία στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας; @plural {n} linked file not found|{n} linked files not found Δεν βρέθηκε {n} συνδεδεμένο αρχείο|Δεν βρέθηκαν {n} συνδεδεμένα αρχεία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1ae8b7fae..105146217 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3793,6 +3793,7 @@ digits Malayalam Malayálam Zoom to Selection Zoom a la selección Zoom Tool Herramienta Zoom Zoom with Mouse Wheel Zoom con la rueda del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Desplazar horizontalmente con Cmd/Ctrl + rueda del mouse Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} archivo en {folder} {note}. ¿Mostrar la carpeta del paquete?|{n} archivos en {folder} {note}. ¿Mostrar la carpeta del paquete? @plural {n} linked file not found|{n} linked files not found No se encontró {n} archivo vinculado|No se encontraron {n} archivos vinculados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bf26c1cc3..821935c27 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3668,6 +3668,7 @@ theme Appearance Ulkoasu Yards Jaardit Zoom to Selection Zoomaa valintaan Zoom with Mouse Wheel Zoomaa hiiren rullalla + Scroll Sideways with Cmd/Ctrl + Wheel Vieritä vaakasuunnassa Cmd/Ctrl + hiiren rullalla Scrub Numeric Fields by Dragging Muuta numerokenttiä vetämällä Linux Display Server Linuxin näyttöpalvelin Print Preview Tulostuksen esikatselu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 455af6aa5..f7215a99b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3785,6 +3785,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sur la sélection Zoom Tool Outil Zoom Zoom with Mouse Wheel Zoom avec la molette de la souris + Scroll Sideways with Cmd/Ctrl + Wheel Défilement horizontal avec Cmd/Ctrl + molette Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fichier dans {folder} {note}. Afficher le dossier de l’assemblage ?|{n} fichiers dans {folder} {note}. Afficher le dossier de l’assemblage ? @plural {n} linked file not found|{n} linked files not found {n} fichier lié introuvable|{n} fichiers liés introuvables diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9574c304a..9690969c5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4526,6 +4526,7 @@ mark-style Japanese Japán Zoom to Selection Nagyítás a kijelölésre Zoom Tool Nagyító Zoom with Mouse Wheel Nagyítás egérgörgővel + Scroll Sideways with Cmd/Ctrl + Wheel Vízszintes görgetés Cmd/Ctrl + görgővel @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Fájlok: {n}, mappa: {folder} {note}. Megnyitod a csomag mappáját? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read Nem olvasható mappák vagy fájlok: {n} @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Kihagyott mappák: {n} (például más alkalmazások, a rendszer és a rejtett mappák) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2b4ab48e8..12c1b4c85 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3472,6 +3472,7 @@ theme Appearance Tampilan Yards Yard Zoom to Selection Zum ke seleksi Zoom with Mouse Wheel Zum dengan roda mouse + Scroll Sideways with Cmd/Ctrl + Wheel Gulir horizontal dengan Cmd/Ctrl + roda mouse Scrub Numeric Fields by Dragging Atur nilai kolom numerik dengan menyeret Linux Display Server Server tampilan Linux Print Preview Pratinjau cetak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1f6d0a139..004d41e2c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3830,6 +3830,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sulla selezione Zoom Tool Strumento Zoom Zoom with Mouse Wheel Zoom con la rotellina del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Scorrimento orizzontale con Cmd/Ctrl + rotellina Scrub Numeric Fields by Dragging Regola i campi numerici trascinando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto?|{n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto? @plural {n} linked file not found|{n} linked files not found File collegati non trovati: {n}|File collegati non trovati: {n} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17b2aa06f..716de0b24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3408,6 +3408,7 @@ mark-style Japanese 日本式 Zoom Tool ズームツール Zoom to Selection 選択範囲にズーム Zoom with Mouse Wheel マウスホイールでズーム + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + ホイールで横スクロール Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整 [Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。 [Unnamed] [名称未設定] diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f9545785b..e5d0a28c0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3288,6 +3288,7 @@ theme Appearance იერსახე Yards იარდები Zoom to Selection მონიშნულის მასშტაბირება Zoom with Mouse Wheel მაუსის ბორბლით მასშტაბირება + Scroll Sideways with Cmd/Ctrl + Wheel ჰორიზონტალური გადაადგილება Cmd/Ctrl + ბორბლით Scrub Numeric Fields by Dragging რიცხვითი ველების შეცვლა გადათრევით Linux Display Server Linux-ის ეკრანის სერვერი Print Preview ნახვა ამობეჭდვამდე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ae73d31ac..a8f2c0db4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3397,6 +3397,7 @@ mark-style Japanese 일본식 Zoom Tool 확대/축소 도구 Zoom to Selection 선택 항목에 맞춰 확대/축소 Zoom with Mouse Wheel 마우스 휠로 확대/축소 + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + 휠로 가로 스크롤 Scrub Numeric Fields by Dragging 드래그로 숫자 필드 조절 [Default] is protected: New… starts an editable copy. [Default]는 보호되어 있습니다. 새로 만들기…를 누르면 편집 가능한 복사본이 만들어집니다. [Unnamed] [이름 없음] diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c52207339..e803314e0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3486,6 +3486,7 @@ theme Appearance Vormgeving Yards Yards Zoom to Selection Inzoomen op selectie Zoom with Mouse Wheel Zoomen met muiswiel + Scroll Sideways with Cmd/Ctrl + Wheel Horizontaal scrollen met Cmd/Ctrl + muiswiel Scrub Numeric Fields by Dragging Numerieke velden aanpassen door te slepen Linux Display Server Linux-weergaveserver Print Preview Afdrukvoorbeeld diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f3538c540..591790721 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3607,6 +3607,7 @@ theme Appearance Wygląd Yards Jardy Zoom to Selection Powiększaj do zaznaczenia Zoom with Mouse Wheel Powiększaj kółkiem myszy + Scroll Sideways with Cmd/Ctrl + Wheel Przewijaj w poziomie kółkiem z Cmd/Ctrl Scrub Numeric Fields by Dragging Zmieniaj pola liczbowe przeciąganiem Linux Display Server Serwer wyświetlania Linux Print Preview Podgląd wydruku diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d1028010b..cae6a2fd7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3115,6 +3115,7 @@ digits Malayalam Malaiala Zoom Tool Ferramenta Zoom Zoom to Selection Ampliar para a Seleção Zoom with Mouse Wheel Zoom com a Roda do Mouse + Scroll Sideways with Cmd/Ctrl + Wheel Rolar na Horizontal com Cmd/Ctrl + Roda Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando [Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável. [Unnamed] [Sem Nome] diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f0a56b613..3f1b8e702 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3825,6 +3825,7 @@ digits Malayalam Малаялам Zoom to Selection Масштаб по выделению Zoom Tool Инструмент «Масштаб» Zoom with Mouse Wheel Масштабировать колесом мыши + Scroll Sideways with Cmd/Ctrl + Wheel Прокручивать по горизонтали колесом с Cmd/Ctrl Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета? @plural {n} linked file not found|{n} linked files not found Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9e2a2ce01..3362155f0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3715,6 +3715,7 @@ digits Malayalam Malayalam Zoom to Selection Zooma till markering Zoom Tool Zoomverktyg Zoom with Mouse Wheel Zooma med mushjulet + Scroll Sideways with Cmd/Ctrl + Wheel Rulla horisontellt med Cmd/Ctrl + mushjulet Scrub Numeric Fields by Dragging Ändra numeriska fält genom att dra @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fil i {folder} {note}. Visa paketmappen?|{n} filer i {folder} {note}. Visa paketmappen? @plural {n} linked file not found|{n} linked files not found {n} länkad fil hittades inte|{n} länkade filer hittades inte diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7cd9822fd..9308d7ea6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3602,6 +3602,7 @@ digits Malayalam Malayalam Zoom to Selection Seçimi Yakınlaştır Zoom Tool Yakınlaştırma Aracı Zoom with Mouse Wheel Fare Tekerleği ile Yakınlaştırma + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + Tekerlek ile Yatay Kaydırma Scrub Numeric Fields by Dragging Sayısal Alanları Sürükleyerek Fırçalayın @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} içinde {n} dosya {note}. Paket klasörü gösterilsin mi? @plural {n} linked file not found|{n} linked files not found {n} bağlantılı dosya bulunamadı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2f50e0991..55edd38d5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3823,6 +3823,7 @@ effect Points Точки Zoom to Selection Масштаб виділення Zoom Tool Інструмент «Масштаб» Zoom with Mouse Wheel Масштабувати колесом миші + Scroll Sideways with Cmd/Ctrl + Wheel Прокручувати по горизонталі колесом із Cmd/Ctrl Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета? @plural {n} linked file not found|{n} linked files not found не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d2dc9a478..e8ea0991f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4189,6 +4189,7 @@ mark-style Japanese Kiểu Nhật Zoom to Selection Thu phóng đến vùng chọn Zoom Tool Công cụ thu phóng Zoom with Mouse Wheel Thu phóng bằng con lăn chuột + Scroll Sideways with Cmd/Ctrl + Wheel Cuộn ngang bằng Cmd/Ctrl + con lăn @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} tệp trong {folder} {note}. Hiện thư mục gói? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read {n} thư mục hoặc tệp không thể đọc được @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Đã bỏ qua {n} thư mục, chẳng hạn thư mục của ứng dụng khác, thư mục hệ thống và thư mục ẩn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 100128f3c..4cf9b672f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3680,6 +3680,7 @@ digits Malayalam 马拉雅拉姆数字 Zoom to Selection 缩放到所选对象 Zoom Tool 缩放工具 Zoom with Mouse Wheel 使用鼠标滚轮缩放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滚轮水平滚动 Scrub Numeric Fields by Dragging 拖动以调整数值字段 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 个文件{note}。要显示打包文件夹吗? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 个链接文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d0f9c079e..4aee5622d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4716,6 +4716,7 @@ digits Malayalam 馬來亞拉姆數字 Zoom to Selection 縮放至選取範圍 Zoom Tool 縮放工具 Zoom with Mouse Wheel 使用滑鼠滾輪縮放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滾輪水平捲動 Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 個檔案{note}。要顯示封裝資料夾嗎? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 個連結檔案 diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index c3532da83..8c3fa35d6 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -456,6 +456,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null); let mark = found_mark(ui, d, sp.key); match sp.kind { + // General › Scroll Sideways with Cmd/Ctrl + Wheel acts only while Zoom with Mouse Wheel + // is off: dimmed while it is on. + PrefKind::Bool if sp.key == "ctrlWheelScrollsSideways" => { + ui.add_enabled_ui(!d.bool("zoomWithMouseWheel"), |ui| bool_row(ui, d, sp.key, sp.label)); + } // Units › Numbers Without Units Are Points only tells points from picas: dimmed // unless a unit is Picas. PrefKind::Bool if sp.key == "numbersWithoutUnitsArePoints" => { diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index a43ec6677..9e8ce2804 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -503,6 +503,41 @@ fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() assert!(picas_in_use(d), "enabled with a unit in picas"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel: its checkbox is dimmed while Zoom with +/// Mouse Wheel is checked in the dialog, and a click on it then leaves the setting as it was. +#[test] +fn scroll_sideways_with_cmd_wheel_dims_with_zoom_with_mouse_wheel() { + fn label(s: &egui::Shape, text: &str) -> Option<(egui::Color32, egui::Rect)> { + match s { + egui::Shape::Text(t) if t.galley.text() == text => t.galley.job.sections.first().map(|s| (s.format.color, t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().find_map(|s| label(s, text)), + _ => None, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut a = app(); + let mut frame = |d: &mut Dialog, events: Vec| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| category_fields(ui, &mut a, d, "General")); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| label(&c.shape, "Scroll Sideways with Cmd/Ctrl + Wheel")) + }; + let t = Tokens::get(&ctx); + for (zoom, color, what) in [(false, t.text, "enabled while Zoom with Mouse Wheel is off"), (true, t.text_disabled, "dimmed while it is on")] { + let mut d = Dialog::new("preferences", json!({"zoomWithMouseWheel": zoom, "ctrlWheelScrollsSideways": true})); + let (shown, at) = frame(&mut d, vec![]).unwrap_or_else(|| panic!("no label ({what})")); + assert_eq!(shown, color, "{what}"); + let press = + |pressed| egui::Event::PointerButton { pos: at.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut d, vec![egui::Event::PointerMoved(at.center()), press(true)]); + frame(&mut d, vec![press(false)]); + // A click turns it off while it is enabled, and leaves it on while it is dimmed. + assert_eq!(d.fields["ctrlWheelScrollsSideways"], json!(zoom), "after a click, {what}"); + } +} + /// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the /// choices this version offers are kept (#502). #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e8408cd5..9357927f0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -100,7 +100,7 @@ the two can still win that race. | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | -| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | +| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | diff --git a/docs/mcp.md b/docs/mcp.md index facbe02e4..8ad9232d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3693,6 +3693,12 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. +`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while +`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac +trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a +trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's +`ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held. + `gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document. `file.new` gives a new document that grid, and `document.open`, `file.newFromTemplate` and `file.revert` give it to diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 794ab3fb8..42a9d7011 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -736,6 +736,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). + - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`).