Add Touchpad navigation: Shift to pan, Alt/Option to orbit (#1220)

An opt-in Touchpad navigation preset: Shift + pointer motion pans, Alt/Option + motion orbits, vertical scroll zooms, all without a mouse button. Existing presets are unchanged; view.navigate gains continuous motion for the control channel and scenarios.
This commit is contained in:
Emil Petersson authored and GitHub committed 2026-10-14 22:46:33 -04:00
1 parent b4edf05373
commit bd8db8ea1c
17 files changed
+717 -60

No files matched your search

+7
View File
@@ -197,6 +197,13 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
sum.normalized()
}
/// Whether a dynamically registered canvas handle owns this point before navigation.
pub(crate) fn owns_pointer(app: &SolveApp, proj: &Proj, p: Pos2) -> bool {
// The triad only registers an egui hit widget after a handle is under the pointer.
// Test its geometry before navigation, including the first frame entering a handle.
app.dialog.as_ref().and_then(|d| crate::dialogs_move::triad(app, d)).is_some_and(|t| crate::gizmo::contains_pointer(proj, &t, p))
}
/// Draw the manipulator and the value box (called by the viewport after the model is drawn).
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
// While OK computes, the inputs hold what it computes: no arrow to drag, no box to type in.
+97
View File
@@ -340,7 +340,16 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
}
"ui.move" | "ui.click" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
if x.abs() > 1e6 || y.abs() > 1e6 {
return err("x/y must be within ±1000000 screen points");
}
let pos = egui::pos2(x as f32, y as f32);
let modifiers = if method == "ui.move" {
egui::Modifiers { shift: b("shift"), alt: b("alt"), ctrl: b("ctrl"), command: b("ctrl") || b("cmd"), mac_cmd: b("cmd") }
} else {
egui::Modifiers::NONE
};
app.synthetic.push(egui::Event::ModifiersChanged(modifiers));
app.synthetic.push(egui::Event::PointerMoved(pos));
if method == "ui.click" {
let button = match s("button") {
@@ -366,6 +375,8 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
};
let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
// Positioning precedes the press by a frame: it is not a modifier-only gesture.
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
// `hold`: frames to keep the button down before moving (press-and-hold).
@@ -705,6 +716,92 @@ mod tests {
use super::*;
use crate::Services;
#[test]
fn touchpad_automation_moves_with_modifiers_and_releases_without_buttons() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.nav = "touchpad".into();
h.call("ui.move", json!({"x":800,"y":500}));
let before = h.app.cam;
h.call("ui.move", json!({"x":800,"y":500,"shift":true}));
h.step(&json!({"move":[830,520],"shift":true})).unwrap();
assert_ne!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.yaw, before.yaw);
assert!(!h.ctx.input(|i| i.pointer.any_down()));
h.call("ui.move", json!({"x":830,"y":520,"alt":true}));
h.step(&json!({"move":[850,530],"alt":true})).unwrap();
assert!((h.app.cam.yaw - before.yaw - 0.16).abs() < 1e-6);
let after = h.app.cam;
h.call("ui.move", json!({"x":800,"y":500}));
h.frames(3);
assert_eq!(h.app.cam, after);
assert!(h.ctx.input(|i| i.modifiers.is_none()));
assert!(h.app.session.selection.is_empty());
assert!(h.app.viewport.boxsel.is_none());
}
#[test]
fn touchpad_drag_positioning_releases_previous_motion_modifiers() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.nav = "touchpad".into();
h.call("ui.move", json!({"x":800,"y":500}));
for modifier in ["shift", "alt"] {
let mut params = json!({"x":800,"y":500});
params[modifier] = json!(true);
h.call("ui.move", params);
let before = h.app.cam;
h.call("ui.drag", json!({"x0":900,"y0":500,"x1":930,"y1":520,"shift":true,"steps":2}));
assert_eq!(h.app.cam, before, "positioning a selection drag must not inherit {modifier} motion");
assert!(!h.ctx.input(|i| i.pointer.any_down()));
}
}
#[test]
fn touchpad_text_prefix_does_not_collapse_queued_pointer_frames() {
let mut app = SolveApp::new(Session::default(), Services::default());
let ctx = egui::Context::default();
let (req, _) = ControlRequest::new("ui.text", json!({"text":"prefix"}));
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] {
let (req, _) = ControlRequest::new("ui.move", params);
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
}
for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] {
let mut raw = egui::RawInput::default();
app.raw_input_hook(&mut raw);
let mut out = ctx.run_ui(raw, |ui| {
ui.input(|i| {
assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p)));
assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt));
});
});
out.textures_delta.clear();
}
assert!(app.synthetic.is_empty());
}
#[test]
fn touchpad_automation_preserves_each_queued_moves_modifiers() {
let mut app = SolveApp::new(Session::default(), Services::default());
let ctx = egui::Context::default();
for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] {
let (req, _) = ControlRequest::new("ui.move", params);
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
}
for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] {
let mut raw = egui::RawInput::default();
app.raw_input_hook(&mut raw);
let mut out = ctx.run_ui(raw, |ui| {
ui.input(|i| {
assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p)));
assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt));
assert!(!i.pointer.any_down());
});
});
out.textures_delta.clear();
}
assert!(app.synthetic.is_empty());
}
fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec<u8> {
let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140}));
match handle(app, &egui::Context::default(), &req) {
+5
View File
@@ -238,6 +238,11 @@ pub(crate) fn cancel(ctx: &egui::Context, id: egui::Id) {
ctx.data_mut(|d| d.remove::<State>(id));
}
/// Hit-test before registering the dynamic egui drag widget or changing the camera.
pub(crate) fn contains_pointer(proj: &Proj, t: &Triad, p: Pos2) -> bool {
mm_per_px(proj, t.center).and_then(|scale| shapes(proj, t, scale * LEN_PX)).is_some_and(|sh| hit(&sh, p).is_some())
}
/// Draw the triad and run a drag on it. `step` is the distance snap step.
pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option<Drag> {
let tk = Tokens::get();
+36 -21
View File
@@ -557,7 +557,10 @@ impl SolveApp {
if let Ok(v) = &r {
if id == "view.navigate" {
let to: Camera = serde_json::from_value(v.get("camera").cloned().unwrap_or_default()).map_err(|e| e.to_string())?;
if v.get("fit").and_then(Value::as_bool).unwrap_or(false) {
if v.get("continuous").and_then(Value::as_bool).unwrap_or(false) {
self.cancel_view_animation();
self.cam = to;
} else if v.get("fit").and_then(Value::as_bool).unwrap_or(false) {
let bounds = solidcraft_engine::view::bounds(&self.session);
self.animate_to(self.fitted(to));
self.pending_fit = Some((to, bounds, true));
@@ -569,14 +572,16 @@ impl SolveApp {
sketch_palette::apply_mode(self, id, v);
}
match &r {
Ok(_) => {
Ok(v) => {
// Programmatic callers (the control channel, scripts) run outside a render
// language scope, so resolve the echo from the saved preference instead of the
// thread-local scope. The command's own label is translated too, so the echo
// reads the same way the toolbar button it came from does.
let language = i18n::Locale::for_language(&self.preferences.language);
let label = language.label(command_label(id));
self.session.echo(language.format("ui.command_done", &[("command", label)]));
if !(id == "view.navigate" && v.get("continuous").and_then(Value::as_bool) == Some(true)) {
self.session.echo(language.format("ui.command_done", &[("command", label)]));
}
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
render_workspace::reset_document(self);
self.sketch_choices = sketch_families::Choices::default();
@@ -1140,26 +1145,36 @@ impl SolveApp {
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic.first() {
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }) => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
// egui keeps the modifier keys as input state, changed by their own event.
match self.synthetic.first() {
Some(egui::Event::PointerButton { modifiers, pressed, .. }) => {
// A drag keeps its press's modifiers until the release.
self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::default() };
raw.events.push(egui::Event::ModifiersChanged(*modifiers))
// Text and modifier prefixes may precede a pointer event. Never drain past that event.
let n = self
.synthetic
.iter()
.position(|e| {
matches!(
e,
egui::Event::PointerMoved(_)
| egui::Event::PointerButton { .. }
| egui::Event::PointerGone
| egui::Event::MouseWheel { .. }
| egui::Event::Key { pressed: false, .. }
)
})
.map_or(self.synthetic.len(), |i| i + 1);
for event in self.synthetic.drain(..n) {
match &event {
egui::Event::ModifiersChanged(modifiers) => self.synthetic_mods = *modifiers,
egui::Event::PointerButton { pos, modifiers, pressed, .. } => {
raw.events.push(egui::Event::PointerMoved(*pos));
raw.events.push(egui::Event::ModifiersChanged(*modifiers));
// A drag keeps its press's modifiers until the release.
self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::NONE };
}
egui::Event::Key { modifiers, .. } => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
egui::Event::PointerMoved(_) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)),
_ => {}
}
Some(egui::Event::Key { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)),
_ => {}
raw.events.push(event);
}
let n = n.min(self.synthetic.len());
raw.events.extend(self.synthetic.drain(..n));
}
/// Lay out the whole window.
+69 -11
View File
@@ -30,6 +30,7 @@ use crate::viewport::NavMode;
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
pub const OPENSCAD_NAV: &str = "openscad";
pub const TOUCHPAD_NAV: &str = "touchpad";
/// A mouse button that can drag the camera.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavButton {
@@ -93,7 +94,7 @@ use NavButton::{Left, Middle, Right};
use NavMode::{Orbit, Pan, Zoom};
/// The mouse mappings. The first is the default.
pub const NAV_PRESETS: [NavPreset; 6] = [
pub const NAV_PRESETS: [NavPreset; 7] = [
NavPreset {
id: "fusion",
label: "Fusion",
@@ -159,8 +160,40 @@ pub const NAV_PRESETS: [NavPreset; 6] = [
bind(Middle, NavKeys::NONE, Pan),
],
},
NavPreset {
id: TOUCHPAD_NAV,
label: "Touchpad",
help: "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
reverse_zoom: false,
bindings: &[bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)],
},
];
impl NavPreset {
/// Button-free motion has exact modifiers, unlike the additive drag bindings.
pub fn motion_mode(&self, mods: Modifiers) -> Option<NavMode> {
if self.id != TOUCHPAD_NAV || mods.ctrl || mods.command || mods.mac_cmd {
return None;
}
match (mods.shift, mods.alt) {
(true, false) => Some(Pan),
(false, true) => Some(Orbit),
_ => None,
}
}
fn translated_label(&self) -> &str {
if self.id == TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.label") } else { crate::i18n::tr_label(self.label) }
}
}
impl Prefs {
/// Touchpad scroll zooms without overwriting the user's preference for other presets.
pub fn trackpad_pans(&self) -> bool {
self.nav != TOUCHPAD_NAV && self.trackpad_pan
}
}
/// The preset with this id (the default for an unknown one).
pub fn nav_preset(id: &str) -> &'static NavPreset {
NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0])
@@ -542,7 +575,7 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
/// The model point under a screen position. Over empty space: the centre of the visible bodies
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
use crate::viewport::Hit;
for h in crate::viewport::pick(app, proj, at) {
match h {
@@ -927,18 +960,22 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
1 => {
ui.label(app.tr_label("Pan, zoom, orbit"));
let current_preset_label = app.tr_label(nav_preset(&app.preferences.nav).label);
let current_preset_label = nav_preset(&app.preferences.nav).translated_label();
egui::ComboBox::from_id_salt("pref_nav").selected_text(current_preset_label).show_ui(ui, |ui| {
for p in &NAV_PRESETS {
if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), crate::i18n::tr_label(p.label)).clicked() {
if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.translated_label()).clicked() {
app.preferences.zoom_reverse = p.reverse_zoom;
}
}
});
ui.end_row();
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);
note(ui, crate::i18n::tr_label(&raw_help));
if app.preferences.nav == TOUCHPAD_NAV {
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);
note(ui, crate::i18n::tr_label(&raw_help));
}
ui.label(crate::i18n::tr_label("Context menu"));
ui.checkbox(&mut app.preferences.radial_context_menu, crate::i18n::tr_label("Radial context menu"));
ui.end_row();
@@ -966,9 +1003,12 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
.on_hover_text(crate::i18n::tr_label("Turn off to apply each wheel event immediately, without a smoothing tail"));
ui.end_row();
ui.label(crate::i18n::tr_label("Two-finger scroll pans"));
ui.checkbox(&mut app.preferences.trackpad_pan, "").on_hover_text(crate::i18n::tr_label(
"Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms",
));
let touchpad = app.preferences.nav == TOUCHPAD_NAV;
ui.add_enabled(!touchpad, egui::Checkbox::new(&mut app.preferences.trackpad_pan, "")).on_hover_text(if touchpad {
crate::i18n::tr("nav.touchpad.scroll")
} else {
crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms")
});
ui.end_row();
ui.label(crate::i18n::tr_label("Orbit around"));
ui.horizontal(|ui| {
@@ -1249,7 +1289,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
return Err("`nav` must be fusion, solidworks, inventor, alias, tinkercad or openscad and `msaa` 1, 2, 4 or 8".into());
return Err(format!("`nav` must be one of {} and `msaa` 1, 2, 4 or 8", NAV_PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")));
}
if new.msaa != app.preferences.msaa && !app.msaa_supported.is_empty() && !app.msaa_supported.contains(&new.msaa) {
return Err(format!("this graphics device doesn't support `msaa` {}; it supports {:?}", new.msaa, app.msaa_supported));
@@ -1471,6 +1511,24 @@ mod tests {
assert!(!app.preferences.smooth_wheel_zoom);
}
#[test]
fn touchpad_preset_persists_and_obeys_preference_transactions() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "nav":"touchpad"})).unwrap();
assert_eq!(app.preferences.nav, "touchpad");
assert!(app.preferences.trackpad_pan, "choosing Touchpad must not overwrite the saved scroll preference");
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
control(&mut app, &json!({"open":"Navigation", "nav":"touchpad", "button":"apply"})).unwrap();
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.nav, "touchpad");
control(&mut app, &json!({"button":"defaults"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert_eq!(app.preferences.nav, "touchpad");
}
#[test]
fn openscad_preset_maps_navigation_and_persists() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
+2 -2
View File
@@ -6,7 +6,7 @@
//! - `{"start": "solid.extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}`
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
//! text (with `near`: the one in that label's row)
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT, "shift"?, "alt"?, "ctrl"?, "cmd"?}`,
//! `{"hold": AT, "frames"?}` (press and hold still: Select Other),
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
//! or `{"sketch": [x,y]}` (the sketch being edited)
@@ -399,7 +399,7 @@ impl Harness {
)
} else if let Some(at) = s.get("move") {
let [x, y] = self.at(at)?;
self.call("ui.move", json!({"x": x, "y": y}))
self.call("ui.move", json!({"x": x, "y": y,"shift":flag("shift"),"alt":flag("alt"),"ctrl":flag("ctrl"),"cmd":flag("cmd")}))
} else if let Some(d) = s.get("drag") {
let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?;
let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?;
+318 -21
View File
@@ -66,6 +66,8 @@ pub struct ViewportState {
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken
/// when the button went down, so the drag neither selects nor moves sketch geometry.
pub primary_nav: Option<NavMode>,
/// Button-free gesture and its last owned pointer position, kept through pauses.
modifier_nav: Option<(NavMode, Pos2)>,
/// What a click would pick now (after the active command's filter).
pub hover: Option<Hit>,
pub mouse: Option<Pos2>,
@@ -1988,6 +1990,45 @@ fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option<egui::Pos2>)
})
}
/// Modifier-only navigation never borrows pointer movement from another widget or gesture.
fn modifier_motion(app: &mut SolveApp, ui: &egui::Ui, inside: bool, hover: Option<Pos2>, mods: egui::Modifiers) -> Option<(NavMode, egui::Vec2)> {
let owns = inside
&& ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed())
&& !ui.ctx().egui_wants_keyboard_input()
&& !egui::Popup::is_any_open(ui.ctx())
&& !crate::dim_view::editing()
&& app.viewport.context_menu.is_none()
&& app.viewport.axis_drag.is_none()
&& app.viewport.point_drag.is_none()
&& app.viewport.text_drag.is_none()
&& app.viewport.boxsel.is_none()
&& !hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p)))
&& !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p)));
let next =
hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p)));
let previous = app.viewport.modifier_nav;
if previous.map(|(m, _)| m) != next.map(|(m, _)| m) {
app.viewport.orbit_pivot = None;
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
}
app.viewport.modifier_nav = next;
next.map(|(mode, p)| {
let delta = previous.filter(|(m, _)| *m == mode).map_or(egui::Vec2::ZERO, |(_, last)| p - last);
let delta = if delta.is_finite() && delta.x.abs() <= 1e4 && delta.y.abs() <= 1e4 { delta } else { egui::Vec2::ZERO };
(mode, delta)
})
}
/// Continuous camera changes use the same JSON command as programmatic callers.
fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) {
let stored_fov = app.cam.fov;
params["camera"] = json!(projection(app, rect).cam);
params["viewport"] = json!([rect.width().max(1.0), rect.height().max(1.0)]);
let _ = app.run("view.navigate", params);
app.cam.fov = stored_fov;
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let rect = ui.max_rect();
@@ -2022,7 +2063,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
middle_double_click(app, ui, inside);
// ---- navigation ----
let trackpad_pan = app.preferences.trackpad_pan;
let trackpad_pan = app.preferences.trackpad_pans();
if !app.preferences.smooth_wheel_zoom {
app.viewport.wheel_zoom_raw_until_idle = true;
} else if !ui.input(|i| i.is_scrolling()) {
@@ -2056,23 +2097,43 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// Spread zooms in: the camera distance shrinks by the pinch factor.
let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app));
let anchor = zoom_anchor(app, rect, hover);
app.cancel_view_animation();
app.cam.zoom_at(f, anchor);
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)});
if let Some(anchor) = anchor {
params["anchor"] = json!(anchor);
}
camera_motion(app, rect, params);
} else {
app.cancel_view_animation();
app.cam.zoom_at(f, anchor);
}
}
if inside && scroll != 0.0 {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
let anchor = zoom_anchor(app, rect, hover);
app.cancel_view_animation();
app.cam.zoom_at(f, anchor);
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)});
if let Some(anchor) = anchor {
params["anchor"] = json!(anchor);
}
camera_motion(app, rect, params);
} else {
app.cancel_view_animation();
app.cam.zoom_at(f, anchor);
}
}
// Two-finger scroll pans the model with the fingers.
if inside && pan != egui::Vec2::ZERO {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
app.cancel_view_animation();
app.cam.pan(f64::from(pan.x), f64::from(pan.y), h);
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]}));
} else {
app.cancel_view_animation();
app.cam.pan(f64::from(pan.x), f64::from(pan.y), h);
}
}
// 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).
@@ -2108,16 +2169,35 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
None
};
let dragging = resp.dragged() || (inside && button.is_some());
let mode = if dragging && app.viewport.axis_drag.is_none() {
match button {
Some(b) => crate::prefs::nav_mode(app, b, mods),
None if primary_down => primary_nav,
None => None,
let motion = modifier_motion(app, ui, inside, hover, mods);
let mode = motion.map(|(m, _)| m).or_else(|| {
if dragging && app.viewport.axis_drag.is_none() {
match button {
Some(b) => crate::prefs::nav_mode(app, b, mods),
None if primary_down => primary_nav,
None => None,
}
} else {
None
}
} else {
None
};
if mode == Some(NavMode::Orbit) {
});
if let Some((mode, delta)) = motion {
if delta != egui::Vec2::ZERO {
let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]});
if mode == NavMode::Orbit && app.preferences.orbit_cursor {
let pivot = match app.viewport.orbit_pivot {
Some(pivot) => pivot,
None => {
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
app.viewport.orbit_pivot = Some(pivot);
pivot
}
};
params["pivot"] = json!(pivot);
}
camera_motion(app, rect, params);
}
} else if mode == Some(NavMode::Orbit) {
let press = ui.input(|i| i.pointer.press_origin());
app.cancel_view_animation();
// Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt),
@@ -2148,7 +2228,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
}
if !dragging {
if !dragging && motion.is_none() {
app.viewport.orbit_pivot = None;
}
if app.viewport.spin {
@@ -2166,7 +2246,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
i.stable_dt,
)
});
if pressed || key || dragging {
if pressed || key || dragging || motion.is_some() {
app.viewport.spin = false;
} else {
app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0);
@@ -3354,15 +3434,17 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let items: [(&str, &str); 10] = [
(
"orbit",
if app.preferences.nav == crate::prefs::OPENSCAD_NAV {
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
crate::i18n::tr("nav.touchpad.orbit")
} else if app.preferences.nav == crate::prefs::OPENSCAD_NAV {
"Orbit (left drag on empty space; Shift+left drag box-selects)"
} else {
"Orbit (right drag; hold Shift to snap)"
},
),
("spin", "Continuous orbit (stops at the next click or key)"),
("pan", "Pan (drag; or middle drag)"),
("zoom", "Zoom (drag; or wheel)"),
("pan", if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.pan") } else { "Pan (drag; or middle drag)" }),
("zoom", if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.zoom") } else { "Zoom (drag; or wheel)" }),
("fit", "Fit (F6)"),
("lookat", "Look At the selected face or plane (or the active sketch)"),
("home", "Home view"),
@@ -3384,6 +3466,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|| ui.rect_contains_pointer(axis_rect(app, rect))
|| app.viewport.axis_drag.is_some()
|| app.viewport.nav.is_some()
|| app.viewport.modifier_nav.is_some()
|| app.viewport.spin
|| menu_open;
let mut painter = ui.painter_at(rect);
@@ -4077,6 +4160,220 @@ mod tests {
}
}
#[test]
fn touchpad_modifier_motion_navigates_without_buttons_or_document_changes() {
for alt in [false, true] {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = "touchpad".into();
let p = pos2(350.0, 300.0);
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
let before = app.cam;
let doc = app.session.doc.to_json();
let undo = app.session.undo.len();
let log = app.session.log.len();
let mods = egui::Modifiers { shift: !alt, alt, ..Default::default() };
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods)]);
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(30.0, 20.0))]);
assert!(!ctx.input(|i| i.pointer.any_down()));
if alt {
assert!((app.cam.yaw - before.yaw - 0.24).abs() < 1e-6);
assert_eq!(app.cam.target, before.target);
} else {
assert_ne!(app.cam.target, before.target);
assert_eq!(app.cam.yaw, before.yaw);
}
assert_eq!(app.cam.distance, before.distance);
let after = app.cam;
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert_eq!(app.cam, after, "no drift between pointer events");
assert!(app.viewport.hover.is_none());
}
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), egui::Event::PointerMoved(p)]);
assert_eq!(app.cam, after, "release ends modifier-only navigation");
assert_eq!(app.session.doc.to_json(), doc);
assert_eq!(app.session.undo.len(), undo);
assert_eq!(app.session.log.len(), log, "continuous motion must not flood the command log");
assert!(app.session.selection.is_empty());
assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none());
}
}
#[test]
fn touchpad_motion_uses_the_effective_perspective_projection() {
let mut app = hover_app(false);
app.ui.perspective = true;
app.cam = Camera { yaw: 0.0, pitch: 0.0, distance: 100.0, ..Default::default() };
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let pivot = Vec3::new(15.0, -50.0, 0.0);
let before = projection(&app, rect).to_screen(pivot).unwrap();
camera_motion(&mut app, rect, json!({"motion":"orbit","delta":[20,10],"pivot":pivot}));
let after = projection(&app, rect).to_screen(pivot).unwrap();
assert!(before.distance(after) < 1e-3, "rendered pivot drifted from {before:?} to {after:?}");
assert_eq!(app.cam.fov, 0.0, "the projection toggle remains UI state, not a stored-camera mutation");
}
#[test]
fn touchpad_motion_does_not_move_the_camera_under_a_canvas_triad() {
let ctx = egui::Context::default();
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length":20,"width":20,"height":20})).unwrap();
s.selection = vec![Sel::Body { name: "Body1".into() }];
let mut app = SolveApp::new(s, crate::Services::default());
app.preferences.nav = "touchpad".into();
app.viewport.no_pixels = true;
app.start("solid.move");
app.dialog.as_mut().unwrap().focus = false;
let p = pos2(100.0, 100.0);
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
ctx.memory_mut(|m| {
if let Some(id) = m.focused() {
m.surrender_focus(id);
}
});
let triad = crate::dialogs_move::triad(&app, app.dialog.as_ref().unwrap()).unwrap();
let handle = projection(&app, app.viewport.rect.unwrap()).to_screen(triad.center).unwrap();
let mods = egui::Modifiers { alt: true, ..Default::default() };
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods)]);
assert!(!ctx.egui_wants_keyboard_input());
let before = app.cam;
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]);
assert_eq!(app.cam, before, "a triad handle owns its input before the camera changes");
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert_eq!(app.cam, before, "leaving the handle rearms without a jump");
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(5.0, 0.0))]);
assert_ne!(app.cam.yaw, before.yaw, "the exposed viewport remains navigable with Move open");
}
#[test]
fn touchpad_motion_preserves_input_ownership_and_rearms_without_jumping() {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = "touchpad".into();
app.preferences.orbit_cursor = true;
let p = pos2(350.0, 300.0);
let alt = egui::Modifiers { alt: true, ..Default::default() };
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(alt)]);
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(10.0, 0.0))]);
let pivot = app.viewport.orbit_pivot;
assert!(pivot.is_some());
hover_frame(&mut app, &ctx, vec![]);
assert_eq!(app.viewport.orbit_pivot, pivot, "pauses keep the same orbit pivot");
let before = app.cam;
let bar = app.viewport.layout.as_ref().unwrap().navigation.center();
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(bar)]);
assert_eq!(app.cam, before, "the navigation bar owns its input");
assert!(app.viewport.orbit_pivot.is_none());
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert_eq!(app.cam, before, "reentry must not apply the distance from an overlay");
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(5.0, 0.0))]);
assert_ne!(app.cam.yaw, before.yaw);
let before = app.cam;
for mods in [
egui::Modifiers::NONE,
egui::Modifiers { shift: true, alt: true, ..Default::default() },
egui::Modifiers { shift: true, ctrl: true, ..Default::default() },
egui::Modifiers { alt: true, command: true, mac_cmd: true, ..Default::default() },
] {
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods), egui::Event::PointerMoved(p)]);
assert_eq!(app.cam, before, "unbound combinations must not navigate");
}
let shift = egui::Modifiers { shift: true, ..Default::default() };
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(p)]);
hover_frame(
&mut app,
&ctx,
vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }],
);
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(30.0, 20.0))]);
assert_eq!(app.cam, before, "Shift+left drag must still box-select, not pan");
assert!(app.viewport.boxsel.is_some());
hover_frame(
&mut app,
&ctx,
vec![egui::Event::PointerButton { pos: p + vec2(30.0, 20.0), button: egui::PointerButton::Primary, pressed: false, modifiers: shift }],
);
assert_eq!(app.cam, before, "the release frame still belongs to selection");
}
#[test]
fn touchpad_focus_and_preset_changes_discard_old_motion() {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = "touchpad".into();
let alt = egui::Modifiers { alt: true, ..Default::default() };
let frame = |app: &mut SolveApp, focused: bool, p: Pos2| {
let mut out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
focused,
events: vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(p)],
..Default::default()
},
|ui| show(app, ui),
);
out.textures_delta.clear();
};
frame(&mut app, true, pos2(300.0, 300.0));
frame(&mut app, true, pos2(310.0, 300.0));
let before = app.cam;
frame(&mut app, false, pos2(500.0, 400.0));
frame(&mut app, true, pos2(350.0, 300.0));
assert_eq!(app.cam, before, "focus loss and regain must not jump");
frame(&mut app, true, pos2(360.0, 300.0));
assert!((app.cam.yaw - before.yaw - 0.08).abs() < 1e-6);
app.preferences.nav = "fusion".into();
let before = app.cam;
frame(&mut app, true, pos2(450.0, 300.0));
assert_eq!(app.cam, before);
app.preferences.nav = "touchpad".into();
frame(&mut app, true, pos2(500.0, 300.0));
assert_eq!(app.cam, before, "changing presets must rearm before moving");
app.viewport.spin = true;
frame(&mut app, true, pos2(510.0, 300.0));
assert!(!app.viewport.spin, "pointer-only navigation stops continuous orbit");
assert!((app.cam.yaw - before.yaw - 0.08).abs() < 1e-6);
}
#[test]
fn touchpad_vertical_trackpad_scroll_zooms_without_overwriting_saved_pan() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.nav = "touchpad".into();
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;
for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] {
h.app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase,
});
h.frame();
}
assert!((h.app.cam.distance - before.distance * (-0.18_f64).exp()).abs() < 1e-6);
assert_eq!(h.app.cam.target, before.target);
assert!(h.app.preferences.trackpad_pan);
let after = h.app.cam;
h.frames(60);
assert_eq!(h.app.cam, after);
h.app.preferences.nav = "fusion".into();
for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] {
h.app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase,
});
h.frame();
}
assert_eq!(h.app.cam.distance, after.distance);
assert_ne!(h.app.cam.target, after.target);
}
#[test]
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
for gpu in [false, true] {