feat(ui): trackpad pan with two fingers, zoom with pinch (#386)

Two-finger scroll pans the model instead of zooming; pinch-to-zoom
zooms toward the cursor and honors reverse zoom. Trackpad scrolls
arrive bracketed (Start..End, momentum included) while mouse wheels
send bare moves, so phases tell them apart: bracketed gestures pan,
bare vertical input zooms and bare horizontal input pans (thumb wheel).
Mouse line/page input keeps zooming, and ui.scroll now sends line
input so scripts and agents are unaffected. New Navigation preference
'Two-finger scroll pans' (default on) restores the old behaviour
when off.
This commit is contained in:
Konrad authored and GitHub committed 2026-10-12 16:15:22 -04:00
1 parent 829912c6c5
commit 3c94f59d43
5 files changed
+351 -42

No files matched your search

+29
View File
@@ -194,6 +194,8 @@ pub struct Prefs {
pub zoom_to_cursor: bool,
/// Spread discrete wheel input across frames (off: apply each event immediately).
pub smooth_wheel_zoom: bool,
/// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms.
pub trackpad_pan: 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,
@@ -221,6 +223,7 @@ impl Default for Prefs {
ui_scale: 1.0,
zoom_to_cursor: true,
smooth_wheel_zoom: true,
trackpad_pan: true,
look_at_anim: true,
look_at_speed: 1.0,
up_axis: "z".into(),
@@ -322,6 +325,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.zoom_reverse = d.zoom_reverse;
p.zoom_to_cursor = d.zoom_to_cursor;
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
p.trackpad_pan = d.trackpad_pan;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
@@ -815,6 +819,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
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("Two-finger scroll pans");
ui.checkbox(&mut app.preferences.trackpad_pan, "").on_hover_text(
"Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms",
);
ui.end_row();
ui.label("Orbit around");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
@@ -1119,6 +1128,26 @@ mod tests {
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
}
#[test]
fn trackpad_pan_defaults_on_persists_and_follows_dialog_buttons() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().trackpad_pan);
assert!(Prefs::default().trackpad_pan);
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap();
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(app.preferences.trackpad_pan);
control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap();
control(&mut app, &json!({"button":"apply"})).unwrap();
assert!(!app.preferences.trackpad_pan);
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert!(!other.preferences.trackpad_pan);
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
assert!(app.preferences.trackpad_pan);
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(!app.preferences.trackpad_pan);
}
#[test]
fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());