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:
1 parent
b4edf05373
commit
bd8db8ea1c
17 files changed
+717
-60
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user