Skip to main content

agg_gui/
touch_emulation.rs

1//! Touch → mouse emulation for the primary finger.
2//!
3//! Companion to [`crate::touch_state`]: while `TouchState` aggregates
4//! two-plus-finger gestures into a per-frame [`crate::MultiTouchInfo`],
5//! this module makes SINGLE-finger touches drive the existing mouse
6//! event pipeline so every widget keeps working without touch-specific
7//! code.  [`crate::widget::App`] owns a [`TouchMouseEmu`] and feeds it
8//! from `App::on_touch_start/move/end/cancel`; the returned [`EmuCmd`]s
9//! are replayed through `App::on_mouse_*`.  Platform shells (web JS,
10//! native winit) therefore forward RAW touches only — they must not
11//! synthesize mouse events themselves, or every event would fire twice.
12//!
13//! The gesture contract (ported from the original JS shell in
14//! `demo/src/app.ts`):
15//!
16//!   - A tap (release before moving [`TOUCH_SCROLL_THRESHOLD`] px) is a
17//!     left click at the release point.
18//!   - A drag past the threshold becomes a MIDDLE-button drag starting
19//!     at the touch-down point — `ScrollView` pans on middle-drag, so a
20//!     finger drag scrolls by the exact finger delta.  Canvas-style
21//!     widgets that want to own finger drags (e.g. the lion demo)
22//!     consume middle-drag instead of left-drag.
23//!   - The moment a second finger lands, single-finger emulation is
24//!     suppressed until every finger lifts: an in-flight middle drag is
25//!     released immediately, and the eventual release emits no click.
26//!     (The multi-finger gesture is reported via `MultiTouchInfo`
27//!     instead.)  This also kills the phantom click a pinch used to
28//!     fire when the first finger barely moved.
29//!
30//! All coordinates are the same screen-space (Y-down, physical-pixel)
31//! units the `App::on_mouse_*` entry points accept.
32
33use std::collections::BTreeSet;
34
35use crate::event::MouseButton;
36use crate::touch_state::{TouchDeviceId, TouchId};
37
38/// Distance (physical px) the primary finger must travel before the
39/// gesture stops being a potential tap and becomes a middle-drag.
40pub const TOUCH_SCROLL_THRESHOLD: f64 = 8.0;
41
42/// A mouse event the emulator wants replayed through `App::on_mouse_*`.
43/// Coordinates are screen-space, matching those entry points.
44#[derive(Copy, Clone, Debug, PartialEq)]
45pub enum EmuCmd {
46    MouseMove(f64, f64),
47    MouseDown(f64, f64, MouseButton),
48    MouseUp(f64, f64, MouseButton),
49    MouseLeave,
50}
51
52/// State machine tracking the primary finger.  Self-contained (keeps
53/// its own active-finger set) so it can be unit-tested without an
54/// `App`.
55#[derive(Default)]
56pub struct TouchMouseEmu {
57    /// Every finger currently down, as reported through on_start/on_end.
58    active: BTreeSet<(TouchDeviceId, TouchId)>,
59    /// The finger driving mouse emulation, if any.
60    primary: Option<(TouchDeviceId, TouchId)>,
61    /// Primary's touch-down position — anchor for the tap-vs-drag
62    /// threshold and the position of the synthetic middle MouseDown.
63    start_pos: (f64, f64),
64    /// Primary's most recent position — used for the tap click and the
65    /// middle-button release (touch-end events carry no coordinates).
66    last_pos: (f64, f64),
67    /// True once the threshold was crossed and a middle-drag is in
68    /// flight.
69    scrolling: bool,
70    /// True once a second finger landed while this primary was active;
71    /// no further mouse events are emitted until all fingers lift.
72    suppressed: bool,
73}
74
75impl TouchMouseEmu {
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    pub fn on_start(&mut self, device: TouchDeviceId, id: TouchId, x: f64, y: f64) -> Vec<EmuCmd> {
81        self.active.insert((device, id));
82        if self.primary.is_none() && self.active.len() == 1 {
83            // First finger of a fresh gesture: becomes primary and moves
84            // the hover position so the eventual tap hits the right
85            // widget (matches the old JS shell's touchstart mouse-move).
86            self.primary = Some((device, id));
87            self.start_pos = (x, y);
88            self.last_pos = (x, y);
89            self.scrolling = false;
90            self.suppressed = false;
91            return vec![EmuCmd::MouseMove(x, y)];
92        }
93        // A second (or later) finger: hand the gesture over to the
94        // multi-touch aggregate.  Release any in-flight middle drag so
95        // ScrollView doesn't keep panning while the user pinches.
96        if self.primary.is_some() && !self.suppressed {
97            self.suppressed = true;
98            if self.scrolling {
99                self.scrolling = false;
100                return vec![EmuCmd::MouseUp(
101                    self.last_pos.0,
102                    self.last_pos.1,
103                    MouseButton::Middle,
104                )];
105            }
106        }
107        Vec::new()
108    }
109
110    pub fn on_move(&mut self, device: TouchDeviceId, id: TouchId, x: f64, y: f64) -> Vec<EmuCmd> {
111        if self.primary != Some((device, id)) || self.suppressed {
112            return Vec::new();
113        }
114        let mut cmds = Vec::new();
115        if !self.scrolling {
116            let dx = x - self.start_pos.0;
117            let dy = y - self.start_pos.1;
118            if (dx * dx + dy * dy).sqrt() >= TOUCH_SCROLL_THRESHOLD {
119                // Threshold crossed: this is a drag, not a tap.  The
120                // middle button goes down at the START point so the
121                // scroll pan covers the full finger travel.
122                self.scrolling = true;
123                cmds.push(EmuCmd::MouseDown(
124                    self.start_pos.0,
125                    self.start_pos.1,
126                    MouseButton::Middle,
127                ));
128            }
129        }
130        // The primary finger always drives the hover position, even
131        // before the threshold — widgets show hover/pressed states.
132        cmds.push(EmuCmd::MouseMove(x, y));
133        self.last_pos = (x, y);
134        cmds
135    }
136
137    pub fn on_end(&mut self, device: TouchDeviceId, id: TouchId) -> Vec<EmuCmd> {
138        self.finish(device, id, /*allow_tap=*/ true)
139    }
140
141    /// Platform cancelled the touch (browser gesture hand-off, palm
142    /// rejection, …).  Like `on_end` but a cancelled tap must NOT click.
143    pub fn on_cancel(&mut self, device: TouchDeviceId, id: TouchId) -> Vec<EmuCmd> {
144        self.finish(device, id, /*allow_tap=*/ false)
145    }
146
147    fn finish(&mut self, device: TouchDeviceId, id: TouchId, allow_tap: bool) -> Vec<EmuCmd> {
148        self.active.remove(&(device, id));
149        let mut cmds = Vec::new();
150        if self.primary == Some((device, id)) {
151            self.primary = None;
152            let (x, y) = self.last_pos;
153            if self.suppressed {
154                // The gesture went multi-touch: the middle button was
155                // already released when the second finger landed, so
156                // only the hover state needs clearing — and crucially,
157                // NO click fires.
158                cmds.push(EmuCmd::MouseLeave);
159            } else if self.scrolling {
160                self.scrolling = false;
161                cmds.push(EmuCmd::MouseUp(x, y, MouseButton::Middle));
162                cmds.push(EmuCmd::MouseLeave);
163            } else if allow_tap {
164                cmds.push(EmuCmd::MouseDown(x, y, MouseButton::Left));
165                cmds.push(EmuCmd::MouseUp(x, y, MouseButton::Left));
166                cmds.push(EmuCmd::MouseLeave);
167            } else {
168                cmds.push(EmuCmd::MouseLeave);
169            }
170        }
171        if self.active.is_empty() {
172            // All fingers lifted: fully reset so the next touch can
173            // become primary even after a suppressed gesture.
174            self.primary = None;
175            self.scrolling = false;
176            self.suppressed = false;
177        }
178        cmds
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    const DEV: TouchDeviceId = TouchDeviceId(0);
187    const F1: TouchId = TouchId(1);
188    const F2: TouchId = TouchId(2);
189    const F3: TouchId = TouchId(3);
190
191    #[test]
192    fn tap_is_left_click_at_release_point() {
193        let mut emu = TouchMouseEmu::new();
194        assert_eq!(
195            emu.on_start(DEV, F1, 100.0, 100.0),
196            vec![EmuCmd::MouseMove(100.0, 100.0)]
197        );
198        // Sub-threshold jitter keeps it a tap and still tracks hover.
199        assert_eq!(
200            emu.on_move(DEV, F1, 103.0, 102.0),
201            vec![EmuCmd::MouseMove(103.0, 102.0)]
202        );
203        assert_eq!(
204            emu.on_end(DEV, F1),
205            vec![
206                EmuCmd::MouseDown(103.0, 102.0, MouseButton::Left),
207                EmuCmd::MouseUp(103.0, 102.0, MouseButton::Left),
208                EmuCmd::MouseLeave,
209            ]
210        );
211    }
212
213    #[test]
214    fn drag_past_threshold_becomes_middle_drag_from_start_point() {
215        let mut emu = TouchMouseEmu::new();
216        emu.on_start(DEV, F1, 50.0, 50.0);
217        // Crossing the threshold: middle-down at the START pos, then move.
218        assert_eq!(
219            emu.on_move(DEV, F1, 62.0, 50.0),
220            vec![
221                EmuCmd::MouseDown(50.0, 50.0, MouseButton::Middle),
222                EmuCmd::MouseMove(62.0, 50.0),
223            ]
224        );
225        // Further moves are plain moves.
226        assert_eq!(
227            emu.on_move(DEV, F1, 70.0, 55.0),
228            vec![EmuCmd::MouseMove(70.0, 55.0)]
229        );
230        assert_eq!(
231            emu.on_end(DEV, F1),
232            vec![
233                EmuCmd::MouseUp(70.0, 55.0, MouseButton::Middle),
234                EmuCmd::MouseLeave
235            ]
236        );
237    }
238
239    #[test]
240    fn second_finger_releases_middle_drag_and_suppresses() {
241        let mut emu = TouchMouseEmu::new();
242        emu.on_start(DEV, F1, 0.0, 0.0);
243        emu.on_move(DEV, F1, 20.0, 0.0); // middle-drag in flight
244                                         // Second finger lands → the drag must end IMMEDIATELY.
245        assert_eq!(
246            emu.on_start(DEV, F2, 100.0, 100.0),
247            vec![EmuCmd::MouseUp(20.0, 0.0, MouseButton::Middle)]
248        );
249        // Suppressed: primary moves emit nothing.
250        assert_eq!(emu.on_move(DEV, F1, 40.0, 0.0), Vec::<EmuCmd>::new());
251        // Primary release: hover clear only, NO click, NO middle-up.
252        assert_eq!(emu.on_end(DEV, F1), vec![EmuCmd::MouseLeave]);
253        assert_eq!(emu.on_end(DEV, F2), Vec::<EmuCmd>::new());
254        // A fresh finger afterwards becomes primary again.
255        assert_eq!(
256            emu.on_start(DEV, F3, 5.0, 5.0),
257            vec![EmuCmd::MouseMove(5.0, 5.0)]
258        );
259    }
260
261    #[test]
262    fn pinch_with_still_primary_fires_no_phantom_click() {
263        let mut emu = TouchMouseEmu::new();
264        let mut all = Vec::new();
265        all.extend(emu.on_start(DEV, F1, 100.0, 100.0));
266        all.extend(emu.on_start(DEV, F2, 200.0, 100.0));
267        all.extend(emu.on_move(DEV, F1, 98.0, 100.0)); // tiny move, < threshold
268        all.extend(emu.on_move(DEV, F2, 205.0, 100.0));
269        all.extend(emu.on_end(DEV, F2));
270        all.extend(emu.on_end(DEV, F1));
271        assert!(
272            !all.iter()
273                .any(|c| matches!(c, EmuCmd::MouseDown(_, _, MouseButton::Left))),
274            "a pinch must never synthesize a left click, got: {all:?}"
275        );
276    }
277
278    #[test]
279    fn cancel_during_drag_releases_middle_without_click() {
280        let mut emu = TouchMouseEmu::new();
281        emu.on_start(DEV, F1, 0.0, 0.0);
282        emu.on_move(DEV, F1, 30.0, 0.0);
283        assert_eq!(
284            emu.on_cancel(DEV, F1),
285            vec![
286                EmuCmd::MouseUp(30.0, 0.0, MouseButton::Middle),
287                EmuCmd::MouseLeave
288            ]
289        );
290    }
291
292    #[test]
293    fn cancel_before_threshold_fires_no_click() {
294        let mut emu = TouchMouseEmu::new();
295        emu.on_start(DEV, F1, 0.0, 0.0);
296        assert_eq!(emu.on_cancel(DEV, F1), vec![EmuCmd::MouseLeave]);
297    }
298
299    #[test]
300    fn non_primary_fingers_never_emit() {
301        let mut emu = TouchMouseEmu::new();
302        emu.on_start(DEV, F1, 0.0, 0.0);
303        emu.on_start(DEV, F2, 50.0, 50.0);
304        assert_eq!(emu.on_move(DEV, F2, 80.0, 80.0), Vec::<EmuCmd>::new());
305        assert_eq!(emu.on_end(DEV, F2), Vec::<EmuCmd>::new());
306        // F1 was suppressed by F2's arrival; its release is leave-only.
307        assert_eq!(emu.on_end(DEV, F1), vec![EmuCmd::MouseLeave]);
308    }
309}