From 1ae6e9beb425fdfaf767c4c4942245973fce8ba6 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Mon, 12 Oct 2026 21:27:20 -0400 Subject: [PATCH] Ctrl+left drag on empty space orbits in every navigation preset (#471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A left press with Ctrl (or Cmd) held, no Shift or Alt, on empty space orbits for that press, unless the preset binds Ctrl+left itself. It only orbits once the pointer moves: a Ctrl+click still adds to the selection, and Ctrl+drag on sketch geometry still drags it without snapping. Works with a tool active, without adding geometry. Ctrl+box selection from empty space becomes the orbit; Shift+box selection still adds. Preferences › Navigation and the UI tour mention it. Test drives every preset through real pointer events. Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/prefs.rs | 2 +- crates/ui-egui/src/viewport.rs | 81 +++++++++++++++++++++++++++++----- docs/book/src/guide/ui-tour.md | 3 +- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 10d904dd..51a406ec 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -816,7 +816,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }); ui.end_row(); let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; - note(ui, &format!("{}{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help)); + note(ui, &format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help)); ui.label("Context menu"); ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu"); ui.end_row(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 794bc5aa..f35736bd 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1726,20 +1726,24 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let // go (kept through the release frame, so the end of the drag doesn't select either). if primary_pressed && inside { - let openscad_orbit = app.preferences.nav == crate::prefs::OPENSCAD_NAV - && app.tool.is_none() - && app.dialog.is_none() - && app.viewport.context_menu.is_none() - && !mods.shift - && hover.is_some_and(|p| { - let proj = projection(app, rect); - pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none() - }); - app.viewport.primary_nav = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit { + let empty_space = |app: &SolveApp| { + app.viewport.context_menu.is_none() + && hover.is_some_and(|p| { + let proj = projection(app, rect); + pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none() + }) + }; + let openscad_orbit = + app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app); + let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit { crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods) } else { None }; + // Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click + // still selects, and Ctrl on geometry still drags it without snapping. + let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app); + app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit)); } else if !primary_down && !primary_released { app.viewport.primary_nav = None; } @@ -3094,6 +3098,63 @@ mod tests { app } + #[test] + fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + for action in ["orbit", "tool", "point", "click"] { + 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 (start, end) = match action { + "point" => (point, point + vec2(30.0, 20.0)), + "click" => (point, point), + _ => (point - vec2(60.0, 60.0), point - vec2(20.0, 30.0)), + }; + if action == "tool" { + app.start("sketch.line"); + } + let before = app.cam; + let undo = app.session.undo.len(); + let modifiers = egui::Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..Default::default() }; + let frame = |app: &mut SolveApp, mut events: Vec| { + events.insert(0, egui::Event::ModifiersChanged(modifiers)); + hover_frame(app, &ctx, events); + }; + frame( + &mut app, + vec![ + egui::Event::PointerMoved(start), + egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers }, + ], + ); + frame(&mut app, vec![egui::Event::PointerMoved(end)]); + frame(&mut app, vec![]); + match action { + "orbit" | "tool" => { + assert_ne!(app.cam, before, "{preset}: Ctrl+left drag on empty space orbits ({action})"); + assert!(app.viewport.boxsel.is_none(), "{preset}: no box selection while orbiting"); + } + "point" => { + assert_eq!(app.cam, before, "{preset}: Ctrl+drag on sketch geometry is not an orbit"); + assert!(app.viewport.point_drag.is_some(), "{preset}: Ctrl+drag still drags the point"); + } + _ => assert_eq!(app.cam, before, "{preset}: a Ctrl+click does not orbit"), + } + frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]); + frame(&mut app, vec![]); + match action { + "click" => assert!(!app.session.selection.is_empty(), "{preset}: Ctrl+click still selects"), + "tool" => assert_eq!(app.session.undo.len(), undo, "{preset}: the orbit drag adds no geometry"), + "orbit" => assert!(app.session.selection.is_empty(), "{preset}: the end of the orbit selects nothing"), + _ => {} + } + } + } + } + #[test] fn openscad_left_drag_preserves_editing_and_selection() { for action in ["point", "box", "tool"] { diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 6f3b18b2..f09cfa53 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -20,7 +20,8 @@ trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans; off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways. Shift+middle orbits freely - in the default and Inventor presets. Shift+right-drag snaps to the nearest standard or + in the default and Inventor presets. In every preset, Ctrl+left drag (Cmd on macOS) that starts + on empty space orbits; a Ctrl+click still adds to the selection. Shift+right-drag snaps to the nearest standard or diagonal view; Shift does not request snapping when the preset already requires it to orbit. The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed axis tip to look along that axis; click the aligned tip again to look from the opposite side.