Merge pull request #202 from 0xlillith/feat/173-immediate-wheel-zoom

feat(ui): allow disabling wheel zoom smoothing
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-11 20:36:41 -04:00
commit 18d487a9bd
4 files changed
+209 -62

No files matched your search

+28
View File
@@ -58,6 +58,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,
@@ -84,6 +86,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(),
@@ -184,6 +187,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.radial_context_menu = d.radial_context_menu;
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;
@@ -577,6 +581,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");
@@ -782,6 +790,26 @@ mod tests {
assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply");
}
#[test]
fn wheel_smoothing_persists_and_follows_dialog_buttons() {
assert!(serde_json::from_value::<Prefs>(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());
+175 -61
View File
@@ -62,6 +62,8 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>,
/// 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<Pos2>,
/// 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<Pos2>, 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<Pos2>, 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]
+5
View File
@@ -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.
+1 -1
View File
@@ -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 |