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}