Ctrl+left drag on empty space orbits in every navigation preset (#471)

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 <[email protected]>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 21:27:20 -04:00
1 parent fc1d70cc3b
commit 1ae6e9beb4
3 files changed
+74 -12

No files matched your search

+1 -1
View File
@@ -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();
+71 -10
View File
@@ -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<egui::Event>| {
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"] {
+2 -1
View File
@@ -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.