feat(ui): trackpad pan with two fingers, zoom with pinch (#386)
Two-finger scroll pans the model instead of zooming; pinch-to-zoom zooms toward the cursor and honors reverse zoom. Trackpad scrolls arrive bracketed (Start..End, momentum included) while mouse wheels send bare moves, so phases tell them apart: bracketed gestures pan, bare vertical input zooms and bare horizontal input pans (thumb wheel). Mouse line/page input keeps zooming, and ui.scroll now sends line input so scripts and agents are unaffected. New Navigation preference 'Two-finger scroll pans' (default on) restores the old behaviour when off.
This commit is contained in:
1 parent
829912c6c5
commit
3c94f59d43
5 files changed
+351
-42
No files matched your search
@@ -12,8 +12,9 @@
|
||||
//! `ui.sketchToScreen {points: [[x,y]…]}` (screen points of active-sketch coordinates); `ui.editFeature {feature}` (edit dialog)
|
||||
//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate)
|
||||
//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs)
|
||||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
|
||||
//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}`
|
||||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`: real
|
||||
//! pointer input in screen points; `ui.scroll {x, y, delta}`: mouse-wheel input in
|
||||
//! points (zooms); `ui.key {key, cmd?, shift?}`, `ui.text {text}`
|
||||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless
|
||||
//! CPU render of the model with the current camera and the section the viewport shows (no
|
||||
//! window needed)
|
||||
@@ -414,9 +415,13 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"ui.scroll" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
|
||||
// Mouse-wheel semantics (lines): zooms regardless of the trackpad pan
|
||||
// preference, so scripts and agents keep working. Trackpads report points.
|
||||
let speed = ctx.options(|o| o.input_options.line_scroll_speed).max(1.0);
|
||||
let lines = f("delta").unwrap_or(-100.0) / f64::from(speed);
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, f("delta").unwrap_or(-100.0) as f32),
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, lines as f32),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
|
||||
@@ -194,6 +194,8 @@ pub struct Prefs {
|
||||
pub zoom_to_cursor: bool,
|
||||
/// Spread discrete wheel input across frames (off: apply each event immediately).
|
||||
pub smooth_wheel_zoom: bool,
|
||||
/// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms.
|
||||
pub trackpad_pan: bool,
|
||||
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
@@ -221,6 +223,7 @@ impl Default for Prefs {
|
||||
ui_scale: 1.0,
|
||||
zoom_to_cursor: true,
|
||||
smooth_wheel_zoom: true,
|
||||
trackpad_pan: true,
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
@@ -322,6 +325,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.zoom_reverse = d.zoom_reverse;
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
|
||||
p.trackpad_pan = d.trackpad_pan;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.space_mouse_enabled = d.space_mouse_enabled;
|
||||
p.space_mouse_speed = d.space_mouse_speed;
|
||||
@@ -815,6 +819,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "")
|
||||
.on_hover_text("Turn off to apply each wheel event immediately, without a smoothing tail");
|
||||
ui.end_row();
|
||||
ui.label("Two-finger scroll pans");
|
||||
ui.checkbox(&mut app.preferences.trackpad_pan, "").on_hover_text(
|
||||
"Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms",
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
@@ -1119,6 +1128,26 @@ mod tests {
|
||||
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_pan_defaults_on_persists_and_follows_dialog_buttons() {
|
||||
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().trackpad_pan);
|
||||
assert!(Prefs::default().trackpad_pan);
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap();
|
||||
control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert!(app.preferences.trackpad_pan);
|
||||
control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap();
|
||||
control(&mut app, &json!({"button":"apply"})).unwrap();
|
||||
assert!(!app.preferences.trackpad_pan);
|
||||
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert!(!other.preferences.trackpad_pan);
|
||||
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
|
||||
assert!(app.preferences.trackpad_pan);
|
||||
control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert!(!app.preferences.trackpad_pan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_map_the_mouse_and_prefs_round_trip() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
|
||||
+308
-36
@@ -67,6 +67,12 @@ pub struct ViewportState {
|
||||
pub spin: bool,
|
||||
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
|
||||
wheel_zoom_raw_until_idle: bool,
|
||||
/// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so
|
||||
/// two-finger input is not replayed as zoom after it pans.
|
||||
trackpad_until_idle: bool,
|
||||
/// Inside a bracketed trackpad scroll (`Start` without its `End` yet):
|
||||
/// point moves pan, bare moves are a mouse.
|
||||
trackpad_gesture: bool,
|
||||
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
|
||||
pub axis_drag: Option<Pos2>,
|
||||
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
|
||||
@@ -1529,32 +1535,110 @@ pub fn delete_selection(app: &mut SolveApp) {
|
||||
crate::delete::delete_selection(app);
|
||||
}
|
||||
|
||||
/// Raw wheel/trackpad input for one frame, split by source.
|
||||
///
|
||||
/// Mouse wheels report lines or pages; trackpads report points. Trackpad
|
||||
/// scrolls arrive bracketed (`Start` … `Move` … `End`, momentum included)
|
||||
/// while mouse wheels send bare `Move` events, so phases tell them apart.
|
||||
struct RawWheel {
|
||||
/// Vertical mouse-wheel input in points (zooms).
|
||||
zoom: f32,
|
||||
/// Trackpad point input, routed like the wheel (zooms when panning is off).
|
||||
point_zoom: f32,
|
||||
/// Bracketed two-finger scroll in points (pans when panning is on).
|
||||
pan: egui::Vec2,
|
||||
/// Bare horizontal input in points (thumb wheel: always pans sideways).
|
||||
mouse_pan: egui::Vec2,
|
||||
}
|
||||
|
||||
/// Route wheel points to the vertical axis, matching egui's scroll modifiers.
|
||||
fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifiers, options: egui::InputOptions) -> f32 {
|
||||
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
|
||||
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
|
||||
if horizontal && !vertical {
|
||||
0.0
|
||||
} else if vertical && !horizontal {
|
||||
points.x + points.y
|
||||
} else {
|
||||
points.y
|
||||
}
|
||||
}
|
||||
|
||||
/// Match egui's wheel units and modifier routing, without its temporal smoothing.
|
||||
fn immediate_wheel_delta(input: &egui::InputState, options: egui::InputOptions, page_height: f32) -> f32 {
|
||||
///
|
||||
/// `gesture` tracks an ongoing bracketed trackpad scroll (`Start` … `End`):
|
||||
/// moves inside one pan, bare `Move` input is a mouse (vertical zooms,
|
||||
/// horizontal pans). Only point input brackets; line/page input always zooms.
|
||||
fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel {
|
||||
let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO };
|
||||
for event in &input.events {
|
||||
let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue };
|
||||
if modifiers.matches_any(options.zoom_modifier) {
|
||||
continue;
|
||||
}
|
||||
let point = matches!(unit, egui::MouseWheelUnit::Point);
|
||||
match phase {
|
||||
egui::TouchPhase::Start => {
|
||||
if point {
|
||||
*gesture = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
|
||||
if point {
|
||||
*gesture = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
egui::TouchPhase::Move => {}
|
||||
}
|
||||
let points = match unit {
|
||||
egui::MouseWheelUnit::Point => *delta,
|
||||
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
|
||||
egui::MouseWheelUnit::Page => *delta * page_height,
|
||||
};
|
||||
if point && *gesture {
|
||||
out.pan += points;
|
||||
// Legacy path only (ignored while panning is on).
|
||||
out.point_zoom += route_wheel_vertical(points, *modifiers, options);
|
||||
} else if point {
|
||||
// Bare moves are a mouse: vertical zooms, horizontal pans sideways.
|
||||
// A wheel needs the opposite sign of fingers for the same motion.
|
||||
out.zoom += route_wheel_vertical(points, *modifiers, options);
|
||||
out.mouse_pan += egui::vec2(-points.x, 0.0);
|
||||
} else {
|
||||
out.zoom += route_wheel_vertical(points, *modifiers, options);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Combined pinch-to-zoom factor for one frame (1 = no pinch input).
|
||||
fn pinch_factor(input: &egui::InputState) -> f32 {
|
||||
input
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
let egui::Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move } = event else { return None };
|
||||
if modifiers.matches_any(options.zoom_modifier) {
|
||||
return None;
|
||||
}
|
||||
let delta = match unit {
|
||||
egui::MouseWheelUnit::Point => *delta,
|
||||
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
|
||||
egui::MouseWheelUnit::Page => *delta * page_height,
|
||||
};
|
||||
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
|
||||
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
|
||||
Some(if horizontal && !vertical {
|
||||
0.0
|
||||
} else if vertical && !horizontal {
|
||||
delta.x + delta.y
|
||||
} else {
|
||||
delta.y
|
||||
})
|
||||
let egui::Event::Zoom(f) = event else { return None };
|
||||
(*f).is_finite().then_some(*f).filter(|f| *f > 0.0)
|
||||
})
|
||||
.sum()
|
||||
.fold(1.0, |acc, f| acc * f)
|
||||
}
|
||||
|
||||
/// Zoom anchor: the model point under the cursor, else the target's depth.
|
||||
/// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
|
||||
fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option<egui::Pos2>) -> Option<Vec3> {
|
||||
hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
|
||||
let proj = projection(app, rect);
|
||||
// Toward the model's surface under the cursor, else the target's depth.
|
||||
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
|
||||
return q;
|
||||
}
|
||||
let (o, d) = proj.ray(p);
|
||||
let n = app.cam.back();
|
||||
let den = d.dot(n);
|
||||
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
@@ -1587,38 +1671,58 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
||||
|
||||
// ---- navigation ----
|
||||
let trackpad_pan = app.preferences.trackpad_pan;
|
||||
if !app.preferences.smooth_wheel_zoom {
|
||||
app.viewport.wheel_zoom_raw_until_idle = true;
|
||||
} else if !ui.input(|i| i.is_scrolling()) {
|
||||
app.viewport.wheel_zoom_raw_until_idle = false;
|
||||
}
|
||||
let scroll = if app.viewport.wheel_zoom_raw_until_idle {
|
||||
let options = ui.ctx().options(|o| o.input_options);
|
||||
let page_height = ui.ctx().viewport_rect().height();
|
||||
ui.input(|i| immediate_wheel_delta(i, options, page_height))
|
||||
let options = ui.ctx().options(|o| o.input_options);
|
||||
let page_height = ui.ctx().viewport_rect().height();
|
||||
let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture));
|
||||
if trackpad_pan && raw.pan != egui::Vec2::ZERO {
|
||||
// Swallow egui's smoothing tail while the trackpad runs (a wheel-only
|
||||
// tail ends the moment trackpad input starts; mixed input is rare).
|
||||
app.viewport.trackpad_until_idle = true;
|
||||
} else if !ui.input(|i| i.is_scrolling()) {
|
||||
app.viewport.trackpad_until_idle = false;
|
||||
// Self-healing: with no scroll activity no gesture can be mid-flight
|
||||
// (its End was lost); a stuck flag would pan future mouse input.
|
||||
app.viewport.trackpad_gesture = false;
|
||||
}
|
||||
let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan;
|
||||
let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) {
|
||||
raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom }
|
||||
} else {
|
||||
scroll
|
||||
};
|
||||
// Pinch-to-zoom (trackpad): direct manipulation, never smoothed.
|
||||
let pinch = ui.input(pinch_factor);
|
||||
let h = rect.height().max(1.0) as f64;
|
||||
if inside && pinch != 1.0 {
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
// 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 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();
|
||||
// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
|
||||
let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
|
||||
let proj = projection(app, rect);
|
||||
// Toward the model's surface under the cursor, else the target's depth.
|
||||
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
|
||||
return q;
|
||||
}
|
||||
let (o, d) = proj.ray(p);
|
||||
let n = app.cam.back();
|
||||
let den = d.dot(n);
|
||||
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
|
||||
});
|
||||
let anchor = zoom_anchor(app, rect, hover);
|
||||
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);
|
||||
}
|
||||
// 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 {
|
||||
@@ -1734,6 +1838,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// events. Testing delta made the highlight reappear whenever a held drag paused.
|
||||
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
|
||||
let navigating = scroll != 0.0
|
||||
|| pinch != 1.0
|
||||
|| pan != egui::Vec2::ZERO
|
||||
|| middle
|
||||
|| secondary_down
|
||||
|| (primary_nav.is_some() && primary_down)
|
||||
@@ -3401,6 +3507,7 @@ mod tests {
|
||||
fn immediate_wheel_zoom_applies_normalized_input_once() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
h.app.preferences.trackpad_pan = false;
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
let before = h.app.cam;
|
||||
@@ -3433,6 +3540,7 @@ mod tests {
|
||||
fn wheel_zoom_setting_changes_do_not_replay_smoothing() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.app.preferences.trackpad_pan = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
@@ -3466,12 +3574,176 @@ mod tests {
|
||||
assert_eq!(h.app.cam, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_two_finger_scroll_pans_without_zooming() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
assert!(h.app.preferences.trackpad_pan, "two-finger pan is the default");
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
let gesture =
|
||||
|phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(60.0, -40.0), modifiers: egui::Modifiers::NONE, phase };
|
||||
// A bracketed trackpad gesture pans (measured macOS Start…End bracketing).
|
||||
let before = h.app.cam;
|
||||
h.app.synthetic.push(gesture(egui::TouchPhase::Start));
|
||||
h.frame();
|
||||
h.app.synthetic.push(gesture(egui::TouchPhase::Move));
|
||||
h.frame();
|
||||
assert_eq!(h.app.cam.half_height(), before.half_height(), "trackpad scroll must not zoom");
|
||||
assert_ne!(h.app.cam.target, before.target, "trackpad scroll must pan");
|
||||
h.app.synthetic.push(gesture(egui::TouchPhase::End));
|
||||
h.frame();
|
||||
let after = h.app.cam;
|
||||
h.frames(30);
|
||||
assert_eq!(h.app.cam, after, "pan must not drift once input stops");
|
||||
// Bare moves are a mouse: vertical zooms…
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
assert!((h.app.cam.half_height() - after.half_height() * (-0.18_f64).exp()).abs() < 1e-6);
|
||||
// …and horizontal pans without zooming (thumb wheel).
|
||||
let zoomed = h.app.cam;
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(40.0, 0.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
assert_eq!(h.app.cam.half_height(), zoomed.half_height());
|
||||
assert_ne!(h.app.cam.target, zoomed.target);
|
||||
// Opting out restores the legacy wheel behaviour for all point input.
|
||||
h.app.preferences.trackpad_pan = false;
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
assert!((h.app.cam.half_height() - zoomed.half_height() * (-0.18_f64).exp()).abs() < 1e-6);
|
||||
// Legacy mode also zooms bracketed input instead of panning it.
|
||||
let legacy = h.app.cam.half_height();
|
||||
let legacy_target = h.app.cam.target;
|
||||
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.half_height() - legacy * (-0.18_f64).exp()).abs() < 1e-6);
|
||||
assert_eq!(h.app.cam.target, legacy_target);
|
||||
// The thumb wheel pans sideways even with panning off: it is a mouse.
|
||||
let h0 = h.app.cam.half_height();
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(40.0, 0.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
assert_eq!(h.app.cam.half_height(), h0);
|
||||
assert_ne!(h.app.cam.target, legacy_target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinch_gesture_zooms_and_ignores_noise() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
let height = h.app.cam.half_height();
|
||||
let target = h.app.cam.target;
|
||||
h.app.synthetic.push(egui::Event::Zoom(1.2));
|
||||
h.frame();
|
||||
let zoomed = h.app.cam.half_height();
|
||||
assert!(((zoomed - height / 1.2) / (height / 1.2)).abs() < 1e-6, "spread must zoom in");
|
||||
assert_eq!(h.app.cam.target, target, "centre zoom keeps the target");
|
||||
h.app.preferences.zoom_reverse = true;
|
||||
let height = h.app.cam.half_height();
|
||||
h.app.synthetic.push(egui::Event::Zoom(1.2));
|
||||
h.frame();
|
||||
let flipped = h.app.cam.half_height();
|
||||
assert!(((flipped - height * 1.2) / (height * 1.2)).abs() < 1e-6, "reverse flips the pinch direction");
|
||||
h.app.synthetic.push(egui::Event::Zoom(f32::NAN));
|
||||
h.app.synthetic.push(egui::Event::Zoom(0.0));
|
||||
h.app.synthetic.push(egui::Event::Zoom(1.0));
|
||||
let stopped = h.app.cam;
|
||||
h.frame();
|
||||
assert_eq!(h.app.cam, stopped, "noise must leave the camera still");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_gestures_over_dialogs_do_not_move_the_view() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
assert!(app.preferences.trackpad_pan);
|
||||
app.viewport.no_pixels = true;
|
||||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||
let mut time = 0.0;
|
||||
let pan =
|
||||
|phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, -20.0), modifiers: egui::Modifiers::NONE, phase };
|
||||
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, events: Vec<egui::Event>| {
|
||||
time += 1.0 / 60.0;
|
||||
let mut input = events;
|
||||
if let Some(p) = pointer {
|
||||
input.insert(0, egui::Event::PointerMoved(p));
|
||||
}
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||||
time: Some(time),
|
||||
events: input,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
|
||||
crate::prefs::show(app, ui.ctx());
|
||||
},
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
for _ in 0..4 {
|
||||
frame(&mut app, None, Vec::new());
|
||||
}
|
||||
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||
let over = window.center();
|
||||
let height = app.cam.half_height();
|
||||
let target = app.cam.target;
|
||||
// A full gesture plus pinch over the dialog stays in the popup.
|
||||
for events in
|
||||
[vec![pan(egui::TouchPhase::Start)], vec![pan(egui::TouchPhase::Move), egui::Event::Zoom(1.2)], vec![pan(egui::TouchPhase::End)]]
|
||||
{
|
||||
frame(&mut app, Some(over), events);
|
||||
assert_eq!(app.cam.half_height(), height, "trackpad input over a dialog must stay in the popup");
|
||||
assert_eq!(app.cam.target, target);
|
||||
}
|
||||
let exposed = pos2(40.0, 700.0);
|
||||
assert!(!window.contains(exposed));
|
||||
frame(&mut app, Some(exposed), Vec::new());
|
||||
frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Start)]);
|
||||
frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Move)]);
|
||||
assert_eq!(app.cam.half_height(), height);
|
||||
assert_ne!(app.cam.target, target, "the exposed viewport still pans");
|
||||
frame(&mut app, Some(exposed), vec![egui::Event::Zoom(1.2)]);
|
||||
assert_ne!(app.cam.half_height(), height, "the exposed viewport still pinch-zooms");
|
||||
frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::End)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
|
||||
for smooth in [true, false] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
app.preferences.smooth_wheel_zoom = smooth;
|
||||
app.preferences.trackpad_pan = false;
|
||||
app.viewport.no_pixels = true;
|
||||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||
let mut time = 0.0;
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
row gives that item's menu. Rows drag into groups and other components.
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
|
||||
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
|
||||
preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). Shift+middle orbits freely
|
||||
< preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a
|
||||
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
|
||||
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
|
||||
|
||||
@@ -76,7 +76,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
|
||||
Reference in new issue
Block a user