From a5003da9d776fe4cfcd9c75d4727d4d2cf00a105 Mon Sep 17 00:00:00 2001 From: James Aita Date: Sat, 17 Oct 2026 04:09:48 -0400 Subject: [PATCH] Navigation: hold Space to pan (#1754) Thanks @styles01. --- crates/ui-egui/src/navigation.rs | 15 ++++++++++++-- crates/ui-egui/src/prefs.rs | 2 +- crates/ui-egui/src/viewport.rs | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 383f1bf7..6f443da3 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -387,8 +387,19 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e } else if !primary_down && !primary_released { app.viewport.primary_nav = None; } - // The fixed navigation mode (navigation bar) or the preset's left-button binding. - let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); + // Hold Space for a momentary pan: the hand tool of design apps. It overrides whatever the + // left button would otherwise do - selecting, dragging geometry, the preset's binding or the + // navigation bar's fixed mode - and lasts only while the key is held. Space still types in a + // text field, and while a dimension's text is being edited, so those keep the keyboard. + let space_pan = (inside || primary_down) + && !ui.ctx().egui_wants_keyboard_input() + && !crate::dim_view::editing() + && ui.input(|i| i.key_down(egui::Key::Space)); + if space_pan { + ui.ctx().set_cursor_icon(if primary_down { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + // The fixed navigation mode (navigation bar), a held Space, or the preset's left-button binding. + let primary_nav = if space_pan { Some(NavMode::Pan) } else { app.viewport.nav.or(app.viewport.primary_nav) }; let button = if middle { Some(crate::prefs::NavButton::Middle) } else if secondary_down { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 989c9f2a..453ce12b 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1139,7 +1139,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { note(ui, crate::i18n::tr("nav.touchpad.help")); } else { let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; - let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help); + let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; hold Space to pan; the wheel zooms", nav_preset(&app.preferences.nav).help); note(ui, crate::i18n::tr_label(&raw_help)); } ui.label(crate::i18n::tr_label("Context menu")); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 5ee09c0d..cd0b69db 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -4111,6 +4111,40 @@ mod tests { app } + fn space(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::default() } + } + + /// Holding Space is a momentary pan: the drag moves the view rather than the geometry under the + /// pointer, in every preset. The point is over the sketch's own point, so without Space this + /// drag would drag it (and the undo stack would grow). + #[test] + fn holding_space_pans_instead_of_dragging_geometry() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let ctx = egui::Context::default(); + let mut app = hover_app(false); + app.preferences.nav = preset.into(); + app.ui.show_origin = false; + hover_frame(&mut app, &ctx, vec![]); + let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); + let before = app.cam; + let undo = app.session.undo.len(); + let press = |pressed: bool, at: Pos2| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }; + hover_frame(&mut app, &ctx, vec![space(true)]); + hover_frame(&mut app, &ctx, vec![space(true), egui::Event::PointerMoved(point), press(true, point)]); + hover_frame(&mut app, &ctx, vec![space(true), egui::Event::PointerMoved(point + vec2(40.0, 25.0))]); + assert_ne!(app.cam, before, "{preset}: holding Space must pan the view"); + assert_eq!(app.session.undo.len(), undo, "{preset}: holding Space must not edit the design"); + assert!(app.viewport.point_drag.is_none(), "{preset}: holding Space must not drag geometry"); + hover_frame(&mut app, &ctx, vec![press(false, point + vec2(40.0, 25.0)), space(false)]); + } + } + #[test] fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() { for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {