From 780b54489e5b4a32c4aa847091a8bc9ab57806fa Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 13 Oct 2026 22:25:13 +0300 Subject: [PATCH] feat(input): touch screen gestures on the drawing One finger already worked as the mouse. Two or more fingers now make a gesture instead of fighting over the cursor: two drag to pan and pinch to zoom about the fingers, three drag to orbit; a quick two-finger tap opens the context menu, a three-finger tap zooms to the extents and a quick three-finger swipe up shows the isometric view. While a gesture runs the drawing area's own pointer handling keeps out, and the press the first finger started is dropped. Refs #906 Co-Authored-By: Claude Opus 5.5 --- src/app/mod.rs | 8 ++ src/app/update/mod.rs | 13 ++ src/app/update/viewport.rs | 74 ++++++++++++ src/app/view/mod.rs | 1 + src/input/mod.rs | 1 + src/input/touch.rs | 242 +++++++++++++++++++++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 src/input/touch.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index e017199c8..5fd2a51ca 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -638,6 +638,10 @@ pub(super) struct OpenCADStudio { zoom_wheel_reversed: bool, /// Scroll pans and Ctrl+scroll zooms (touchpads); see `scroll_intent`. touchpad_pan: bool, + /// Multi-finger gestures on a touch screen. + touch: crate::input::touch::TouchGestures, + /// Where the drawing area sits in the window, learned from a lone finger. + touch_offset: Option, /// Interface scale in percent, applied through the window scale factor. ui_scale_percent: u16, /// Mouse-wheel zoom sensitivity (ZOOMFACTOR), held inside @@ -2347,6 +2351,8 @@ pub enum Message { SpaceMousePreferences, SpaceMouseDriverSettings, SpaceMouseDetails, + /// A touch screen finger event, for multi-finger gestures. + Touch(iced::touch::Event), /// One magnification delta from a trackpad pinch, positive zooming in. TrackpadPinch(f32), ControlRequest(control::Envelope), @@ -4571,6 +4577,8 @@ impl OpenCADStudio { polar_increment_deg: 45.0, zoom_wheel_reversed: false, touchpad_pan: false, + touch: Default::default(), + touch_offset: None, ui_scale_percent: 100, zoom_factor: 60, zoom_factor_input: 60.to_string(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a1f199706..6f88b92ca 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -605,6 +605,7 @@ impl OpenCADStudio { match msg { Message::SpaceMouseWake => self.on_spacemouse_wake(), Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification), + Message::Touch(event) => self.on_touch(event), Message::SpaceMouseFrame(time) => { self.spacemouse .frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.); @@ -4343,6 +4344,18 @@ impl OpenCADStudio { // ── Cursor / viewport messages ───────────────────────────────── Message::CursorMoved(p, viewport) => self.on_cursor_moved(p, viewport), + // A multi-finger gesture owns the pointer while it runs. + Message::ViewportMove(_) + | Message::ViewportLeftPress + | Message::ViewportLeftRelease + | Message::PaneMove(..) + | Message::PanePress(_) + | Message::PaneRelease(_) + if self.touch.active() => + { + Task::none() + } + Message::ViewportMove(p) => self.on_viewport_move(p), Message::ViewportExit => self.on_viewport_exit(), diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 158f4699f..644478cef 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5943,6 +5943,80 @@ properties={:.1}ms picked={}", /// Pinch the active view to zoom, as reported by the trackpad monitor /// (`src/input/trackpad.rs`). The pivot is the cursor, like the wheel, and /// the direction is the fingers' own, so ZOOMWHEEL does not apply to it. + /// A touch screen finger event: multi-finger gestures pan, zoom and + /// orbit the view (see `input::touch`). + pub(super) fn on_touch(&mut self, event: iced::touch::Event) -> Task { + use crate::input::touch::TouchAction; + let i = self.active_tab; + if self.tabs[i].is_start { + return Task::none(); + } + // A lone finger drives the cursor, so where it is in the window and + // in the drawing area tells the drawing area's offset. + if let iced::touch::Event::FingerMoved { position, .. } = event { + let cursor = self.tabs[i].scene.selection.borrow().input.last_move_pos; + if let Some(cursor) = cursor.filter(|_| self.touch.fingers() == 1) { + self.touch_offset = Some(position - cursor); + } + } + let mut tasks = Vec::new(); + for action in self.touch.event(event) { + match action { + TouchAction::Cancel => { + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.drag_move = None; + self.tabs[i].scene.remember_current_view(); + self.clear_navigation_hover(i); + } + TouchAction::Pan(delta) => { + self.pan_active_view(i, delta.x, delta.y, Instant::now()); + } + TouchAction::Zoom { factor, centre } => { + // Zoom about the fingers once the drawing area's place in + // the window is known; until then about the first finger. + if let Some(offset) = self.touch_offset { + self.tabs[i].scene.selection.borrow_mut().input.last_move_pos = + Some(centre - offset); + } + tasks.push(self.zoom_view_at_cursor(pinch_zoom_steps(factor - 1.0))); + } + TouchAction::Orbit(delta) => { + if self.tabs[i].scene.active_viewport.is_some() { + self.tabs[i].scene.orbit_active_viewport(delta.x, delta.y); + } else if self.tabs[i].scene.current_layout == "Model" { + let scene = &self.tabs[i].scene; + let (vw, vh) = scene.selection.borrow().view.vp_size; + let bounds = scene.active_model_tile_bounds(vw, vh); + let pivot = scene + .orbit_pivot() + .or_else(|| scene.view_center_surface_pivot(bounds)) + .unwrap_or_else(|| scene.camera.borrow().target); + self.tabs[i].scene.refresh_projection_bounds(); + self.tabs[i] + .scene + .camera + .borrow_mut() + .orbit(delta.x, delta.y, Some(pivot)); + } + self.tabs[i].scene.camera_generation += 1; + } + TouchAction::Tap(2) => { + tasks.push(self.update(Message::ViewportRightPress)); + tasks.push(self.update(Message::ViewportRightRelease)); + } + TouchAction::Tap(_) => { + tasks.push(Task::done(Message::Command("ZOOM E".to_string()))); + } + TouchAction::SwipeUp => tasks.push(self.apply_view_preset("SEISO")), + } + } + Task::batch(tasks) + } + pub(super) fn on_pinch_zoom(&mut self, magnification: f32) -> Task { if magnification == 0.0 { return Task::none(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7919d8246..0512da3ab 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2717,6 +2717,7 @@ impl OpenCADStudio { .map(|_| Message::SpaceMouseWake), crate::input::trackpad::subscription().map(Message::TrackpadPinch), event::listen_with(|event, _, id| match event { + iced::Event::Touch(touch) => Some(Message::Touch(touch)), iced::Event::Window(window::Event::Focused) => { Some(Message::SpaceMouseFocus(id, true)) } diff --git a/src/input/mod.rs b/src/input/mod.rs index a9634889b..a079716f2 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,3 +1,4 @@ //! Device integrations. Camera ownership and CAD commands stay in the app. pub(crate) mod spacemouse; +pub(crate) mod touch; pub(crate) mod trackpad; diff --git a/src/input/touch.rs b/src/input/touch.rs new file mode 100644 index 000000000..325e61d1b --- /dev/null +++ b/src/input/touch.rs @@ -0,0 +1,242 @@ +//! Touch screen gestures on the drawing. One finger needs nothing here: the +//! window moves the cursor with it and the drawing area takes its press and +//! lift as the left button. Two or more fingers become a gesture instead — +//! two drag to pan and pinch to zoom, three drag to orbit; a quick tap with +//! two opens the context menu, with three zooms to the extents, and three +//! swiped up show the isometric view. + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use iced::touch; +use iced::{Point, Vector}; + +/// What a touch event asks the view to do. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum TouchAction { + /// A second finger came down: drop the press the first one started. + Cancel, + Pan(Vector), + /// Scale the view by `factor` (above 1 zooms in) about `centre`, in + /// window coordinates. + Zoom { factor: f32, centre: Point }, + Orbit(Vector), + Tap(usize), + SwipeUp, +} + +struct Gesture { + started: Instant, + most_fingers: usize, + travel: f32, + /// How far the fingers moved together, without the jumps of fingers + /// landing or lifting. + swept: Vector, + centre: Point, + spread: f32, +} + +/// A tap is quicker and stiller than this. +const TAP_TIME: Duration = Duration::from_millis(300); +const TAP_TRAVEL: f32 = 12.0; +/// A swipe is a quick long sweep; a slower one is just an orbit. +const SWIPE_TRAVEL: f32 = 80.0; +const SWIPE_TIME: Duration = Duration::from_millis(500); +/// The lift ending a gesture reaches the drawing area as a left release +/// just before or after the gesture ends; this long after, it is let be. +const SETTLE: Duration = Duration::from_millis(150); + +#[derive(Default)] +pub(crate) struct TouchGestures { + fingers: BTreeMap, + gesture: Option, + ended: Option, +} + +impl TouchGestures { + /// A gesture is running (or just ended), so the drawing area's own + /// pointer handling should keep out. + pub(crate) fn active(&self) -> bool { + self.gesture.is_some() || self.ended.is_some_and(|ended| ended.elapsed() < SETTLE) + } + + /// Fingers on the screen. + pub(crate) fn fingers(&self) -> usize { + self.fingers.len() + } + + fn centre_and_spread(&self) -> (Point, f32) { + let count = self.fingers.len().max(1) as f32; + let (x, y) = self + .fingers + .values() + .fold((0.0, 0.0), |(x, y), point| (x + point.x, y + point.y)); + let centre = Point::new(x / count, y / count); + let spread = self + .fingers + .values() + .map(|point| point.distance(centre)) + .sum::() + / count; + (centre, spread) + } + + pub(crate) fn event(&mut self, event: touch::Event) -> Vec { + match event { + touch::Event::FingerPressed { id, position } => { + self.fingers.insert(id.0, position); + if self.fingers.len() < 2 { + return Vec::new(); + } + let (centre, spread) = self.centre_and_spread(); + match &mut self.gesture { + Some(gesture) => { + gesture.most_fingers = gesture.most_fingers.max(self.fingers.len()); + gesture.centre = centre; + gesture.spread = spread; + Vec::new() + } + None => { + self.gesture = Some(Gesture { + started: Instant::now(), + most_fingers: self.fingers.len(), + travel: 0.0, + swept: Vector::new(0.0, 0.0), + centre, + spread, + }); + vec![TouchAction::Cancel] + } + } + } + touch::Event::FingerMoved { id, position } => { + if let Some(finger) = self.fingers.get_mut(&id.0) { + *finger = position; + } + let count = self.fingers.len(); + let (centre, spread) = self.centre_and_spread(); + let Some(gesture) = self.gesture.as_mut() else { + return Vec::new(); + }; + let delta = centre - gesture.centre; + gesture.travel += (delta.x * delta.x + delta.y * delta.y).sqrt(); + gesture.swept = gesture.swept + delta; + let mut actions = Vec::new(); + if count == 2 { + actions.push(TouchAction::Pan(delta)); + if gesture.spread > 1.0 && spread > 1.0 { + gesture.travel += (spread - gesture.spread).abs(); + actions.push(TouchAction::Zoom { + factor: spread / gesture.spread, + centre, + }); + } + } else if count >= 3 { + actions.push(TouchAction::Orbit(delta)); + } + gesture.centre = centre; + gesture.spread = spread; + actions + } + touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. } => { + self.fingers.remove(&id.0); + if !self.fingers.is_empty() { + // The rest carry on from where they are, without a jump. + let (centre, spread) = self.centre_and_spread(); + if let Some(gesture) = self.gesture.as_mut() { + gesture.centre = centre; + gesture.spread = spread; + } + return Vec::new(); + } + let Some(gesture) = self.gesture.take() else { + return Vec::new(); + }; + self.ended = Some(Instant::now()); + let swept = gesture.swept; + if gesture.started.elapsed() < TAP_TIME && gesture.travel < TAP_TRAVEL { + vec![TouchAction::Tap(gesture.most_fingers)] + } else if gesture.most_fingers >= 3 + && gesture.started.elapsed() < SWIPE_TIME + && swept.y < -SWIPE_TRAVEL + && swept.y.abs() > 2.0 * swept.x.abs() + { + vec![TouchAction::SwipeUp] + } else { + Vec::new() + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn press(id: u64, x: f32, y: f32) -> touch::Event { + touch::Event::FingerPressed { + id: touch::Finger(id), + position: Point::new(x, y), + } + } + fn moved(id: u64, x: f32, y: f32) -> touch::Event { + touch::Event::FingerMoved { + id: touch::Finger(id), + position: Point::new(x, y), + } + } + fn lift(id: u64) -> touch::Event { + touch::Event::FingerLifted { + id: touch::Finger(id), + position: Point::ORIGIN, + } + } + + #[test] + fn two_fingers_pan_pinch_and_tap() { + let mut touch = TouchGestures::default(); + assert!(touch.event(press(1, 100.0, 100.0)).is_empty()); + assert_eq!(touch.event(press(2, 200.0, 100.0)), vec![TouchAction::Cancel]); + assert!(touch.active()); + // Both fingers slide right: a pan, no zoom. + touch.event(moved(1, 110.0, 100.0)); + let actions = touch.event(moved(2, 210.0, 100.0)); + assert!(matches!(actions[0], TouchAction::Pan(v) if (v.x - 5.0).abs() < 1e-3)); + // They spread apart: zoom in. + let actions = touch.event(moved(2, 260.0, 100.0)); + let zooms_in = + |a: &TouchAction| matches!(a, TouchAction::Zoom { factor, .. } if *factor > 1.0); + assert!(actions.iter().any(zooms_in)); + touch.event(lift(1)); + assert!(touch.event(lift(2)).is_empty()); + + // A quick still touch with two fingers is a tap. + touch.event(press(3, 100.0, 100.0)); + touch.event(press(4, 150.0, 100.0)); + touch.event(lift(3)); + assert_eq!(touch.event(lift(4)), vec![TouchAction::Tap(2)]); + } + + #[test] + fn three_fingers_orbit_and_swipe_up() { + let mut touch = TouchGestures::default(); + touch.event(press(1, 100.0, 300.0)); + touch.event(press(2, 150.0, 300.0)); + touch.event(press(3, 200.0, 300.0)); + let mut orbited = false; + for step in 1..=10 { + let y = 300.0 - 15.0 * step as f32; + for (id, x) in [(1, 100.0), (2, 150.0), (3, 200.0)] { + orbited |= touch + .event(moved(id, x, y)) + .iter() + .any(|a| matches!(a, TouchAction::Orbit(_))); + } + } + assert!(orbited); + touch.event(lift(1)); + touch.event(lift(2)); + assert_eq!(touch.event(lift(3)), vec![TouchAction::SwipeUp]); + } +}