forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
fcf1cedf52
commit
780b54489e
6 files changed
+339
No files matched your search
@@ -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<iced::Vector>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Message> {
|
||||
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<Message> {
|
||||
if magnification == 0.0 {
|
||||
return Task::none();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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<u64, Point>,
|
||||
gesture: Option<Gesture>,
|
||||
ended: Option<Instant>,
|
||||
}
|
||||
|
||||
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::<f32>()
|
||||
/ count;
|
||||
(centre, spread)
|
||||
}
|
||||
|
||||
pub(crate) fn event(&mut self, event: touch::Event) -> Vec<TouchAction> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user