From 16bf3ba593dc9b6dd8594c427fd58d99756f7277 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:07:23 +0200 Subject: [PATCH] feat(ui): allow disabling wheel zoom smoothing --- crates/ui-egui/src/prefs.rs | 28 ++++ crates/ui-egui/src/viewport.rs | 236 ++++++++++++++++++++++++--------- docs/book/src/guide/ui-tour.md | 5 + docs/control-protocol.md | 2 +- 4 files changed, 209 insertions(+), 62 deletions(-) diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index f30de5fb..2b4e3d61 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -56,6 +56,8 @@ pub struct Prefs { pub ui_scale: f32, /// The wheel zooms toward the point under the cursor (else toward the view's centre). pub zoom_to_cursor: bool, + /// Spread discrete wheel input across frames (off: apply each event immediately). + pub smooth_wheel_zoom: bool, /// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast. pub look_at_anim: bool, pub look_at_speed: f32, @@ -81,6 +83,7 @@ impl Default for Prefs { language: "en".into(), ui_scale: 1.0, zoom_to_cursor: true, + smooth_wheel_zoom: true, look_at_anim: true, look_at_speed: 1.0, up_axis: "z".into(), @@ -180,6 +183,7 @@ fn defaults(app: &mut SolveApp, page: usize) { p.nav = d.nav; p.zoom_reverse = d.zoom_reverse; p.zoom_to_cursor = d.zoom_to_cursor; + p.smooth_wheel_zoom = d.smooth_wheel_zoom; p.orbit_cursor = d.orbit_cursor; p.space_mouse_enabled = d.space_mouse_enabled; p.space_mouse_speed = d.space_mouse_speed; @@ -570,6 +574,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.checkbox(&mut app.preferences.zoom_to_cursor, "") .on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)"); ui.end_row(); + ui.label("Smooth wheel zoom"); + ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "") + .on_hover_text("Turn off to apply each wheel event immediately, without a smoothing tail"); + ui.end_row(); ui.label("Orbit around"); ui.horizontal(|ui| { ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre"); @@ -753,6 +761,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result(json!({})).unwrap().smooth_wheel_zoom); + let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap(); + control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert!(app.preferences.smooth_wheel_zoom); + control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap(); + control(&mut app, &json!({"button":"apply"})).unwrap(); + control(&mut app, &json!({"smooth_wheel_zoom":true, "button":"cancel"})).unwrap(); + assert!(!app.preferences.smooth_wheel_zoom); + let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + other.load_prefs(&app.prefs()); + assert!(!other.preferences.smooth_wheel_zoom); + control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap(); + assert!(app.preferences.smooth_wheel_zoom); + control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert!(!app.preferences.smooth_wheel_zoom); + } + #[test] fn presets_map_the_mouse_and_prefs_round_trip() { let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 62948458..1bfa4e48 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -62,6 +62,8 @@ pub struct ViewportState { pub orbit_pivot: Option, /// Continuous orbit: the view turns about the vertical until the next click or key. pub spin: bool, + /// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends. + wheel_zoom_raw_until_idle: bool, /// A primary press on the axis gizmo: the pointer position used by its last orbit step. pub axis_drag: Option, /// Unsnapped camera while Shift-orbit previews a standard or diagonal view. @@ -1471,6 +1473,34 @@ pub fn delete_selection(app: &mut SolveApp) { crate::delete::delete_selection(app); } +/// Match egui's wheel units and modifier routing, without its temporal smoothing. +fn immediate_wheel_delta(input: &egui::InputState, options: egui::InputOptions, page_height: f32) -> f32 { + input + .events + .iter() + .filter_map(|event| { + let egui::Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move } = event else { return None }; + if modifiers.matches_any(options.zoom_modifier) { + return None; + } + let delta = match unit { + egui::MouseWheelUnit::Point => *delta, + egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed, + egui::MouseWheelUnit::Page => *delta * page_height, + }; + let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier); + let vertical = modifiers.matches_any(options.vertical_scroll_modifier); + Some(if horizontal && !vertical { + 0.0 + } else if vertical && !horizontal { + delta.x + delta.y + } else { + delta.y + }) + }) + .sum() +} + pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let rect = ui.max_rect(); @@ -1499,6 +1529,18 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); // ---- navigation ---- + if !app.preferences.smooth_wheel_zoom { + app.viewport.wheel_zoom_raw_until_idle = true; + } else if !ui.input(|i| i.is_scrolling()) { + app.viewport.wheel_zoom_raw_until_idle = false; + } + let scroll = if app.viewport.wheel_zoom_raw_until_idle { + let options = ui.ctx().options(|o| o.input_options); + let page_height = ui.ctx().viewport_rect().height(); + ui.input(|i| immediate_wheel_delta(i, options, page_height)) + } else { + scroll + }; let h = rect.height().max(1.0) as f64; if inside && scroll != 0.0 { app.viewport.orbit_snap = None; @@ -3113,72 +3155,144 @@ mod tests { } } + #[test] + fn immediate_wheel_zoom_applies_normalized_input_once() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.smooth_wheel_zoom = false; + h.app.preferences.zoom_to_cursor = false; + h.call("ui.move", json!({"x":800,"y":500})); + let before = h.app.cam; + let line = h.ctx.options(|o| o.input_options.line_scroll_speed); + for (unit, amount, points) in + [(egui::MouseWheelUnit::Point, 120.0, 120.0), (egui::MouseWheelUnit::Line, 1.0, line), (egui::MouseWheelUnit::Page, 0.1, 100.0)] + { + let height = h.app.cam.half_height(); + h.app.synthetic.push(egui::Event::MouseWheel { + unit, + delta: vec2(0.0, amount), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.frame(); + let expected = height * (-f64::from(points) * 0.0015).exp(); + assert!((h.app.cam.half_height() - expected).abs() < 1e-6); + let after = h.app.cam; + h.frames(30); + assert_eq!(h.app.cam, after, "idle frames must not replay wheel input"); + } + assert_eq!(h.app.cam.target, before.target); + h.app.preferences.zoom_reverse = true; + let height = h.app.cam.half_height(); + h.call("ui.scroll", json!({"x":800,"y":500,"delta":120})); + assert!((h.app.cam.half_height() - height * 0.18_f64.exp()).abs() < 1e-6); + } + + #[test] + fn wheel_zoom_setting_changes_do_not_replay_smoothing() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.zoom_to_cursor = false; + h.call("ui.move", json!({"x":800,"y":500})); + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.frame(); + assert!(h.ctx.input(|i| i.is_scrolling())); + h.app.preferences.smooth_wheel_zoom = false; + let stopped = h.app.cam; + h.frame(); + assert_eq!(h.app.cam, stopped); + h.app.preferences.smooth_wheel_zoom = true; + h.frames(90); + assert_eq!(h.app.cam, stopped, "re-enabling must not replay the old smoothing tail"); + assert!(!h.app.viewport.wheel_zoom_raw_until_idle); + h.app.preferences.smooth_wheel_zoom = false; + for _ in 0..3 { + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.frame(); + } + assert!((h.app.cam.half_height() - stopped.half_height() * (-0.54_f64).exp()).abs() < 1e-6); + let after = h.app.cam; + h.frames(90); + assert_eq!(h.app.cam, after); + } + #[test] fn shortcuts_window_wheel_does_not_zoom_the_viewport() { - let ctx = egui::Context::default(); - let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default()); - app.viewport.no_pixels = true; - crate::help::run(&mut app, &ctx, "shortcuts"); - let mut time = 0.0; - let mut frame = |app: &mut SolveApp, pointer: Option, wheel: f32| { - time += 1.0 / 60.0; - let mut events = Vec::new(); - if let Some(p) = pointer { - events.push(egui::Event::PointerMoved(p)); + for smooth in [true, false] { + let ctx = egui::Context::default(); + let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default()); + app.preferences.smooth_wheel_zoom = smooth; + app.viewport.no_pixels = true; + crate::help::run(&mut app, &ctx, "shortcuts"); + let mut time = 0.0; + let mut frame = |app: &mut SolveApp, pointer: Option, wheel: f32| { + time += 1.0 / 60.0; + let mut events = Vec::new(); + if let Some(p) = pointer { + events.push(egui::Event::PointerMoved(p)); + } + if wheel != 0.0 { + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, wheel), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + } + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))), + time: Some(time), + events, + ..Default::default() + }, + |ui| { + egui::CentralPanel::default().show(ui, |ui| show(app, ui)); + crate::prefs::show(app, ui.ctx()); + }, + ); + output.textures_delta.clear(); + }; + // Let egui establish the floating window's size and hit-test widgets. + for _ in 0..4 { + frame(&mut app, None, 0.0); } - if wheel != 0.0 { - events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, wheel), - modifiers: egui::Modifiers::NONE, - phase: egui::TouchPhase::Move, - }); - } - let mut output = ctx.run_ui( - egui::RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))), - time: Some(time), - events, - ..Default::default() - }, - |ui| { - egui::CentralPanel::default().show(ui, |ui| show(app, ui)); - crate::prefs::show(app, ui.ctx()); - }, - ); - output.textures_delta.clear(); - }; - // Let egui establish the floating window's size and hit-test widgets. - for _ in 0..4 { - frame(&mut app, None, 0.0); - } - let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); - let list = window.center() + vec2(140.0, 0.0); - let height = app.cam.half_height(); - let target = app.cam.target; - frame(&mut app, Some(list), 0.0); - for n in 0..12 { - frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 }); - assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup"); - assert_eq!(app.cam.target, target); - } - // The window's non-scrolling sidebar must also shield the model. - frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0); - assert_eq!(app.cam.half_height(), height); - // An exposed part of the viewport still accepts wheel navigation with the popup open. - let exposed = pos2(40.0, 700.0); - assert!(!window.contains(exposed)); - frame(&mut app, Some(exposed), 0.0); - frame(&mut app, Some(exposed), -120.0); - assert_ne!(app.cam.half_height(), height); - // Closing the popup returns its former area to the viewport. - app.prefs_window.open = false; - for _ in 0..12 { + let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); + let list = window.center() + vec2(140.0, 0.0); + let height = app.cam.half_height(); + let target = app.cam.target; frame(&mut app, Some(list), 0.0); + for n in 0..12 { + frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 }); + assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup"); + assert_eq!(app.cam.target, target); + } + // The window's non-scrolling sidebar must also shield the model. + frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0); + assert_eq!(app.cam.half_height(), height); + // An exposed part of the viewport still accepts wheel navigation with the popup open. + let exposed = pos2(40.0, 700.0); + assert!(!window.contains(exposed)); + frame(&mut app, Some(exposed), 0.0); + frame(&mut app, Some(exposed), -120.0); + assert_ne!(app.cam.half_height(), height); + // Closing the popup returns its former area to the viewport. + app.prefs_window.open = false; + for _ in 0..12 { + frame(&mut app, Some(list), 0.0); + } + let height = app.cam.half_height(); + frame(&mut app, Some(list), -120.0); + assert_ne!(app.cam.half_height(), height); } - let height = app.cam.half_height(); - frame(&mut app, Some(list), -120.0); - assert_ne!(app.cam.half_height(), height); } #[test] diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index d17fc99d..24dbe7d3 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -39,5 +39,10 @@ Preferences (File › Preferences) hold the theme, the units of new designs, the visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard Shortcuts changes them. +Navigation › **Smooth wheel zoom** is on by default. Turn it off to apply each wheel or +trackpad event immediately, with no smoothing tail. It is independent of **Animate view +changes**, which controls axis views, Look At, Home and Fit. Re-enabling smoothing during +a wheel gesture takes effect after that gesture ends, avoiding leftover zoom being replayed. + Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a lightly damped spring transition and a small boundary margin to prevent flicker. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8e187ef7..416e072b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -70,7 +70,7 @@ rolls the numbering back with the design), so they can go straight into later co | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |