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(self.last_pos.0, self.last_pos.1, MouseButton::Middle)];
101            }
102        }
103        Vec::new()
104    }
105
106    pub fn on_move(&mut self, device: TouchDeviceId, id: TouchId, x: f64, y: f64) -> Vec<EmuCmd> {
107        if self.primary != Some((device, id)) || self.suppressed {
108            return Vec::new();
109        }
110        let mut cmds = Vec::new();
111        if !self.scrolling {
112            let dx = x - self.start_pos.0;
113            let dy = y - self.start_pos.1;
114            if (dx * dx + dy * dy).sqrt() >= TOUCH_SCROLL_THRESHOLD {
115                // Threshold crossed: this is a drag, not a tap.  The
116                // middle button goes down at the START point so the
117                // scroll pan covers the full finger travel.
118                self.scrolling = true;
119                cmds.push(EmuCmd::MouseDown(
120                    self.start_pos.0,
121                    self.start_pos.1,
122                    MouseButton::Middle,
123                ));
124            }
125        }
126        // The primary finger always drives the hover position, even
127        // before the threshold — widgets show hover/pressed states.
128        cmds.push(EmuCmd::MouseMove(x, y));
129        self.last_pos = (x, y);
130        cmds
131    }
132
133    pub fn on_end(&mut self, device: TouchDeviceId, id: TouchId) -> Vec<EmuCmd> {
134        self.finish(device, id, /*allow_tap=*/ true)
135    }
136
137    /// Platform cancelled the touch (browser gesture hand-off, palm
138    /// rejection, …).  Like `on_end` but a cancelled tap must NOT click.
139    pub fn on_cancel(&mut self, device: TouchDeviceId, id: TouchId) -> Vec<EmuCmd> {
140        self.finish(device, id, /*allow_tap=*/ false)
141    }
142
143    fn finish(&mut self, device: TouchDeviceId, id: TouchId, allow_tap: bool) -> Vec<EmuCmd> {
144        self.active.remove(&(device, id));
145        let mut cmds = Vec::new();
146        if self.primary == Some((device, id)) {
147            self.primary = None;
148            let (x, y) = self.last_pos;
149            if self.suppressed {
150                // The gesture went multi-touch: the middle button was
151                // already released when the second finger landed, so
152                // only the hover state needs clearing — and crucially,
153                // NO click fires.
154                cmds.push(EmuCmd::MouseLeave);
155            } else if self.scrolling {
156                self.scrolling = false;
157                cmds.push(EmuCmd::MouseUp(x, y, MouseButton::Middle));
158                cmds.push(EmuCmd::MouseLeave);
159            } else if allow_tap {
160                cmds.push(EmuCmd::MouseDown(x, y, MouseButton::Left));
161                cmds.push(EmuCmd::MouseUp(x, y, MouseButton::Left));
162                cmds.push(EmuCmd::MouseLeave);
163            } else {
164                cmds.push(EmuCmd::MouseLeave);
165            }
166        }
167        if self.active.is_empty() {
168            // All fingers lifted: fully reset so the next touch can
169            // become primary even after a suppressed gesture.
170            self.primary = None;
171            self.scrolling = false;
172            self.suppressed = false;
173        }
174        cmds
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    const DEV: TouchDeviceId = TouchDeviceId(0);
183    const F1: TouchId = TouchId(1);
184    const F2: TouchId = TouchId(2);
185    const F3: TouchId = TouchId(3);
186
187    #[test]
188    fn tap_is_left_click_at_release_point() {
189        let mut emu = TouchMouseEmu::new();
190        assert_eq!(emu.on_start(DEV, F1, 100.0, 100.0), vec![EmuCmd::MouseMove(100.0, 100.0)]);
191        // Sub-threshold jitter keeps it a tap and still tracks hover.
192        assert_eq!(emu.on_move(DEV, F1, 103.0, 102.0), vec![EmuCmd::MouseMove(103.0, 102.0)]);
193        assert_eq!(
194            emu.on_end(DEV, F1),
195            vec![
196                EmuCmd::MouseDown(103.0, 102.0, MouseButton::Left),
197                EmuCmd::MouseUp(103.0, 102.0, MouseButton::Left),
198                EmuCmd::MouseLeave,
199            ]
200        );
201    }
202
203    #[test]
204    fn drag_past_threshold_becomes_middle_drag_from_start_point() {
205        let mut emu = TouchMouseEmu::new();
206        emu.on_start(DEV, F1, 50.0, 50.0);
207        // Crossing the threshold: middle-down at the START pos, then move.
208        assert_eq!(
209            emu.on_move(DEV, F1, 62.0, 50.0),
210            vec![
211                EmuCmd::MouseDown(50.0, 50.0, MouseButton::Middle),
212                EmuCmd::MouseMove(62.0, 50.0),
213            ]
214        );
215        // Further moves are plain moves.
216        assert_eq!(emu.on_move(DEV, F1, 70.0, 55.0), vec![EmuCmd::MouseMove(70.0, 55.0)]);
217        assert_eq!(
218            emu.on_end(DEV, F1),
219            vec![EmuCmd::MouseUp(70.0, 55.0, MouseButton::Middle), EmuCmd::MouseLeave]
220        );
221    }
222
223    #[test]
224    fn second_finger_releases_middle_drag_and_suppresses() {
225        let mut emu = TouchMouseEmu::new();
226        emu.on_start(DEV, F1, 0.0, 0.0);
227        emu.on_move(DEV, F1, 20.0, 0.0); // middle-drag in flight
228        // Second finger lands → the drag must end IMMEDIATELY.
229        assert_eq!(
230            emu.on_start(DEV, F2, 100.0, 100.0),
231            vec![EmuCmd::MouseUp(20.0, 0.0, MouseButton::Middle)]
232        );
233        // Suppressed: primary moves emit nothing.
234        assert_eq!(emu.on_move(DEV, F1, 40.0, 0.0), Vec::<EmuCmd>::new());
235        // Primary release: hover clear only, NO click, NO middle-up.
236        assert_eq!(emu.on_end(DEV, F1), vec![EmuCmd::MouseLeave]);
237        assert_eq!(emu.on_end(DEV, F2), Vec::<EmuCmd>::new());
238        // A fresh finger afterwards becomes primary again.
239        assert_eq!(emu.on_start(DEV, F3, 5.0, 5.0), vec![EmuCmd::MouseMove(5.0, 5.0)]);
240    }
241
242    #[test]
243    fn pinch_with_still_primary_fires_no_phantom_click() {
244        let mut emu = TouchMouseEmu::new();
245        let mut all = Vec::new();
246        all.extend(emu.on_start(DEV, F1, 100.0, 100.0));
247        all.extend(emu.on_start(DEV, F2, 200.0, 100.0));
248        all.extend(emu.on_move(DEV, F1, 98.0, 100.0)); // tiny move, < threshold
249        all.extend(emu.on_move(DEV, F2, 205.0, 100.0));
250        all.extend(emu.on_end(DEV, F2));
251        all.extend(emu.on_end(DEV, F1));
252        assert!(
253            !all.iter().any(|c| matches!(c, EmuCmd::MouseDown(_, _, MouseButton::Left))),
254            "a pinch must never synthesize a left click, got: {all:?}"
255        );
256    }
257
258    #[test]
259    fn cancel_during_drag_releases_middle_without_click() {
260        let mut emu = TouchMouseEmu::new();
261        emu.on_start(DEV, F1, 0.0, 0.0);
262        emu.on_move(DEV, F1, 30.0, 0.0);
263        assert_eq!(
264            emu.on_cancel(DEV, F1),
265            vec![EmuCmd::MouseUp(30.0, 0.0, MouseButton::Middle), EmuCmd::MouseLeave]
266        );
267    }
268
269    #[test]
270    fn cancel_before_threshold_fires_no_click() {
271        let mut emu = TouchMouseEmu::new();
272        emu.on_start(DEV, F1, 0.0, 0.0);
273        assert_eq!(emu.on_cancel(DEV, F1), vec![EmuCmd::MouseLeave]);
274    }
275
276    #[test]
277    fn non_primary_fingers_never_emit() {
278        let mut emu = TouchMouseEmu::new();
279        emu.on_start(DEV, F1, 0.0, 0.0);
280        emu.on_start(DEV, F2, 50.0, 50.0);
281        assert_eq!(emu.on_move(DEV, F2, 80.0, 80.0), Vec::<EmuCmd>::new());
282        assert_eq!(emu.on_end(DEV, F2), Vec::<EmuCmd>::new());
283        // F1 was suppressed by F2's arrival; its release is leave-only.
284        assert_eq!(emu.on_end(DEV, F1), vec![EmuCmd::MouseLeave]);
285    }
286}