agg_gui/touch_state.rs
1//! Multi-touch gesture recogniser.
2//!
3//! The platform shells (web JS, native winit) forward raw touch events
4//! to [`App::on_touch_start/move/end/cancel`]. [`TouchState`] maintains
5//! the set of active touches and, once two or more fingers are down,
6//! aggregates them each frame into a [`MultiTouchInfo`] describing zoom,
7//! rotation, pan, and average pressure relative to the previous frame.
8//!
9//! Widgets that want to react to gestures read the current frame's
10//! aggregate via [`current_multi_touch`], a thread-local written by
11//! `App::paint` at the start of each frame. Single-finger touches are
12//! replayed through the regular mouse pipeline by the core-owned
13//! [`crate::touch_emulation::TouchMouseEmu`], so existing widgets keep
14//! working with no changes.
15//!
16//! The API shape deliberately mirrors egui's (`zoom_delta`,
17//! `rotation_delta`, `translation_delta`, `num_touches`, `center_pos`)
18//! so ports from egui code read cleanly.
19
20use std::cell::RefCell;
21use std::collections::BTreeMap;
22
23use crate::geometry::Point;
24
25// ---------------------------------------------------------------------------
26// Identifier newtypes
27// ---------------------------------------------------------------------------
28
29/// Stable per-device identifier. Different physical input surfaces
30/// (e.g. a laptop's built-in touchscreen and a connected tablet) hash
31/// to different values. The web shell always uses `0` (the browser
32/// doesn't expose multiple touch devices to pages); winit passes
33/// through its device id.
34#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
35pub struct TouchDeviceId(pub u64);
36
37/// Per-finger identifier, stable from Start through End/Cancel. Re-
38/// used after lift — browsers and winit both guarantee identifiers
39/// are unique only for the lifetime of the touch.
40#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
41pub struct TouchId(pub u64);
42
43/// Which phase of the gesture this touch event represents.
44#[derive(Copy, Clone, Debug, PartialEq, Eq)]
45pub enum TouchPhase {
46 /// Finger first made contact.
47 Start,
48 /// Finger moved while in contact.
49 Move,
50 /// Finger lifted normally.
51 End,
52 /// Touch was cancelled by the platform (phone call, gesture
53 /// hand-off to browser, etc.).
54 Cancel,
55}
56
57// ---------------------------------------------------------------------------
58// MultiTouchInfo — the per-frame aggregate
59// ---------------------------------------------------------------------------
60
61/// Gesture aggregate for the current frame, produced when two or more
62/// fingers are on the same device. All deltas are relative to the
63/// previous frame's positions — the widget just accumulates them into
64/// its own angle / scale / translation state (see `LionView` for the
65/// canonical consumer).
66#[derive(Copy, Clone, Debug)]
67pub struct MultiTouchInfo {
68 /// Device that owns these touches. Useful only when the host
69 /// actually distinguishes multiple touchscreens; most apps ignore.
70 pub device_id: TouchDeviceId,
71 /// Number of fingers currently down (always ≥ 2 — a single-finger
72 /// frame produces `None` instead of a [`MultiTouchInfo`]).
73 pub num_touches: usize,
74 /// Multiplicative zoom factor since the last frame. `1.0` means
75 /// "no pinch this frame"; `1.1` means the fingers spread by 10 %.
76 pub zoom_delta: f32,
77 /// Rotation in radians since the last frame. Positive = CCW in
78 /// widget-local (Y-up) space, i.e. visually counter-clockwise on
79 /// screen.
80 pub rotation_delta: f32,
81 /// Translation of the centroid since the last frame, in widget-
82 /// local pixels. Widgets that want the gesture to orbit the pinch
83 /// centre should combine this with `zoom_delta` / `rotation_delta`.
84 pub translation_delta: Point,
85 /// Average `force` across active touches, or `0.0` when the
86 /// platform doesn't report pressure.
87 pub force: f32,
88 /// Centroid of the active touches in app-local coordinates this
89 /// frame. Widgets that want to hit-test "is the gesture over me?"
90 /// compare this against their own absolute bounds.
91 pub center_pos: Point,
92}
93
94// ---------------------------------------------------------------------------
95// TouchState — per-frame gesture recogniser
96// ---------------------------------------------------------------------------
97
98/// One finger's tracked position, updated every Move event.
99#[derive(Copy, Clone, Debug)]
100struct ActiveTouch {
101 /// Latest position reported by the platform.
102 pos: Point,
103 /// Position at the last `update_gesture` call — used as the basis
104 /// for the next delta.
105 prev_pos: Point,
106 /// Latest force (0.0 when unsupported).
107 force: f32,
108}
109
110/// Tracks every active touch across every known device. Lives on
111/// `App`; widgets never see this directly.
112#[derive(Default)]
113pub struct TouchState {
114 active: BTreeMap<(TouchDeviceId, TouchId), ActiveTouch>,
115 /// Result of the most recent `update_gesture` call — `None` while
116 /// fewer than two fingers are down on any one device. Published
117 /// to the thread-local so widgets can read it during paint.
118 last: Option<MultiTouchInfo>,
119 /// Set by Start / End / Cancel so `update_gesture` can reseed
120 /// `prev_pos` on the frame after a finger count change — without
121 /// this, newly-arrived fingers contribute a spurious delta equal
122 /// to their full spread on their first move.
123 topology_changed: bool,
124}
125
126/// Fold an angle (radians) into the canonical `[-pi, pi]` range. Used to
127/// keep each finger's per-frame rotation step honest across the atan2 ±pi
128/// seam before the steps are averaged.
129fn wrap_angle(a: f32) -> f32 {
130 use std::f32::consts::PI;
131 let mut a = a;
132 while a > PI {
133 a -= 2.0 * PI;
134 }
135 while a < -PI {
136 a += 2.0 * PI;
137 }
138 a
139}
140
141impl TouchState {
142 pub fn new() -> Self {
143 Self::default()
144 }
145
146 pub fn on_start(&mut self, device: TouchDeviceId, id: TouchId, pos: Point, force: Option<f32>) {
147 self.active.insert(
148 (device, id),
149 ActiveTouch {
150 pos,
151 prev_pos: pos,
152 force: force.unwrap_or(0.0),
153 },
154 );
155 self.topology_changed = true;
156 }
157
158 pub fn on_move(&mut self, device: TouchDeviceId, id: TouchId, pos: Point, force: Option<f32>) {
159 if let Some(t) = self.active.get_mut(&(device, id)) {
160 t.pos = pos;
161 if let Some(f) = force {
162 t.force = f;
163 }
164 }
165 }
166
167 pub fn on_end_or_cancel(&mut self, device: TouchDeviceId, id: TouchId) {
168 if self.active.remove(&(device, id)).is_some() {
169 self.topology_changed = true;
170 }
171 if self.active.len() < 2 {
172 self.last = None;
173 }
174 }
175
176 /// Recompute the per-frame aggregate. Called by `App` right before
177 /// the multi-touch value is published, so every `paint` / `on_event`
178 /// in the same frame sees consistent deltas.
179 pub fn update_gesture(&mut self) {
180 // Only the most-populated device contributes — the common case
181 // is a single touchscreen, and cross-device gestures aren't a
182 // useful abstraction.
183 let device = self.active.keys().next().map(|(d, _)| *d);
184 let Some(device) = device else {
185 self.last = None;
186 return;
187 };
188 let touches: Vec<ActiveTouch> = self
189 .active
190 .iter()
191 .filter(|((d, _), _)| *d == device)
192 .map(|(_, t)| *t)
193 .collect();
194 if touches.len() < 2 {
195 self.last = None;
196 return;
197 }
198
199 // Centroid (previous vs current) drives the translation delta.
200 let n = touches.len() as f64;
201 let (mut cx, mut cy) = (0.0, 0.0);
202 let (mut pcx, mut pcy) = (0.0, 0.0);
203 for t in &touches {
204 cx += t.pos.x;
205 cy += t.pos.y;
206 pcx += t.prev_pos.x;
207 pcy += t.prev_pos.y;
208 }
209 cx /= n;
210 cy /= n;
211 pcx /= n;
212 pcy /= n;
213
214 // Average pinch + rotation across pairs. Using every
215 // (touch, centroid) ray means the signal scales sensibly with
216 // finger count; egui does the same.
217 let mut zoom_sum = 0.0_f32;
218 let mut rotation_sum = 0.0_f32;
219 let mut force_sum = 0.0_f32;
220 let mut zoom_count = 0;
221 for t in &touches {
222 force_sum += t.force;
223 let dx = (t.pos.x - cx) as f32;
224 let dy = (t.pos.y - cy) as f32;
225 let pdx = (t.prev_pos.x - pcx) as f32;
226 let pdy = (t.prev_pos.y - pcy) as f32;
227 let r = (dx * dx + dy * dy).sqrt();
228 let pr = (pdx * pdx + pdy * pdy).sqrt();
229 if pr > 1.0 && r > 1.0 {
230 zoom_sum += r / pr;
231 // Normalise EACH finger's angular step into `[-pi, pi]`
232 // before summing. A real two-finger twist sweeps every
233 // finger's polar angle around the centroid, so on every
234 // half-turn one finger crosses the atan2 ±pi seam: its raw
235 // `atan2(dy,dx) - atan2(pdy,pdx)` jumps by ~±2pi (a true
236 // +2° step reads as -358°). Averaging that with the other
237 // finger's correct step yields ~-178°, and post-average
238 // normalisation cannot recover the intended small delta.
239 // Wrapping per finger keeps each contribution honest.
240 rotation_sum += wrap_angle(dy.atan2(dx) - pdy.atan2(pdx));
241 zoom_count += 1;
242 }
243 }
244 // Skip producing a frame-delta when topology just changed —
245 // the jump from "no prev_pos" to "current pos" would otherwise
246 // read as a huge one-frame zoom. We still emit an info entry
247 // so widgets can react to finger count; just with zeroed
248 // deltas.
249 let (zoom_delta, rotation_delta) = if self.topology_changed || zoom_count == 0 {
250 (1.0, 0.0)
251 } else {
252 // Per-finger deltas are already wrapped, so their average is
253 // well-behaved; this final wrap is a cheap belt-and-braces
254 // clamp for the pathological many-finger case.
255 let rot = wrap_angle(rotation_sum / zoom_count as f32);
256 (zoom_sum / zoom_count as f32, rot)
257 };
258
259 let translation_delta = if self.topology_changed {
260 Point::new(0.0, 0.0)
261 } else {
262 Point::new(cx - pcx, cy - pcy)
263 };
264
265 self.last = Some(MultiTouchInfo {
266 device_id: device,
267 num_touches: touches.len(),
268 zoom_delta,
269 rotation_delta,
270 translation_delta,
271 force: force_sum / n as f32,
272 center_pos: Point::new(cx, cy),
273 });
274
275 // Latch current positions as the new baseline for the next
276 // frame, then clear the topology flag.
277 for t in self.active.values_mut() {
278 t.prev_pos = t.pos;
279 }
280 self.topology_changed = false;
281 }
282
283 pub fn current(&self) -> Option<MultiTouchInfo> {
284 self.last
285 }
286
287 /// Total number of fingers currently down (across all devices).
288 /// Useful as a lightweight "are we in a gesture?" probe when a
289 /// widget doesn't care about the per-delta aggregate.
290 pub fn active_count(&self) -> usize {
291 self.active.len()
292 }
293}
294
295// ---------------------------------------------------------------------------
296// Thread-local publish / read
297// ---------------------------------------------------------------------------
298
299thread_local! {
300 static CURRENT: RefCell<Option<MultiTouchInfo>> = RefCell::new(None);
301 /// Wall-clock time of the most recent touch lifecycle event
302 /// (`Start` / `Move` / `End` / `Cancel`). Set by `App`'s touch
303 /// entry points. Mouse events the touch shell synthesises arrive
304 /// within milliseconds of a touch event — widgets that need to
305 /// distinguish a touch tap from a desktop click read
306 /// [`last_touch_event_age`] and treat anything under a few tens
307 /// of milliseconds as touch-synthesised.
308 static LAST_TOUCH_EVENT_AT: std::cell::Cell<Option<web_time::Instant>> =
309 const { std::cell::Cell::new(None) };
310 /// Sticky "a real touch has happened this session" latch. Unlike
311 /// [`LAST_TOUCH_EVENT_AT`] (which ages out and only distinguishes
312 /// touch-synthesised mouse events from desktop clicks), this NEVER
313 /// clears on its own: once any touch lifecycle event fires, the
314 /// process is treated as touch-driven for UI *sizing* policy (see
315 /// [`crate::input_profile::touch_ui_active`]). Latching, rather than
316 /// aging out, means a menu that grew to a finger-friendly size the
317 /// instant the user first touched the screen doesn't shrink back to
318 /// desktop dimensions a few frames later. Cleared only by the test
319 /// hook [`clear_last_touch_event_for_testing`].
320 static TOUCH_SEEN_THIS_SESSION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
321}
322
323/// Publish this frame's multi-touch aggregate. Called by
324/// `App::paint` right before painting begins.
325pub fn set_current(info: Option<MultiTouchInfo>) {
326 CURRENT.with(|c| *c.borrow_mut() = info);
327}
328
329/// Fetch the current frame's multi-touch aggregate. Returns `None`
330/// when fewer than two fingers are down on any device, so a widget
331/// writes: `if let Some(mt) = current_multi_touch() { … }`.
332pub fn current_multi_touch() -> Option<MultiTouchInfo> {
333 CURRENT.with(|c| *c.borrow())
334}
335
336/// Record that a touch lifecycle event just fired. Called from
337/// `App::on_touch_start/move/end/cancel`.
338pub(crate) fn note_touch_event() {
339 LAST_TOUCH_EVENT_AT.with(|c| c.set(Some(web_time::Instant::now())));
340 TOUCH_SEEN_THIS_SESSION.with(|c| c.set(true));
341}
342
343/// Whether any real touch lifecycle event has fired this session. Sticky
344/// once set (it does not age out), so it can serve as the runtime-fallback
345/// half of the menu sizing-policy signal in
346/// [`crate::input_profile::touch_ui_active`]: a phone whose shell forgot to
347/// call `set_input_profile` still grows its menus to finger size the moment
348/// the user first touches the screen.
349pub fn touch_seen_this_session() -> bool {
350 TOUCH_SEEN_THIS_SESSION.with(|c| c.get())
351}
352
353/// Time elapsed since the most recent touch lifecycle event, or
354/// `None` if no touch event has ever fired. Mouse events
355/// synthesised from a touchstart / touchend by the web shell arrive
356/// within a millisecond of the touch event — widgets needing to
357/// tell touch-synthesised mouse events apart from real desktop
358/// clicks check this against a small threshold.
359pub fn last_touch_event_age() -> Option<std::time::Duration> {
360 LAST_TOUCH_EVENT_AT.with(|c| c.get()).map(|t| t.elapsed())
361}
362
363/// Forget any prior touch event so the next mouse event reads as
364/// "from desktop" until [`note_touch_event`] runs again. Tests use
365/// this to isolate desktop-mouse scenarios from a sibling test that
366/// just simulated a touch tap.
367#[doc(hidden)]
368pub fn clear_last_touch_event_for_testing() {
369 LAST_TOUCH_EVENT_AT.with(|c| c.set(None));
370 TOUCH_SEEN_THIS_SESSION.with(|c| c.set(false));
371}
372
373// ---------------------------------------------------------------------------
374// Tests
375// ---------------------------------------------------------------------------
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 const DEV: TouchDeviceId = TouchDeviceId(0);
382
383 /// Two fingers land, then `update_gesture` runs once. This is the
384 /// baseline (topology-changed) frame: it should emit a `MultiTouchInfo`
385 /// with num_touches = 2 but zeroed deltas so newly-arrived fingers
386 /// never contribute a spurious one-frame jump.
387 #[test]
388 fn two_fingers_land_emits_zeroed_baseline_frame() {
389 let mut ts = TouchState::new();
390 ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
391 ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
392 ts.update_gesture();
393
394 let info = ts.current().expect("two fingers should produce a gesture");
395 assert_eq!(info.num_touches, 2);
396 assert_eq!(info.zoom_delta, 1.0, "topology frame must not zoom");
397 assert_eq!(info.rotation_delta, 0.0, "topology frame must not rotate");
398 assert_eq!(info.translation_delta.x, 0.0);
399 assert_eq!(info.translation_delta.y, 0.0);
400 }
401
402 /// Pinch-out: after the baseline frame, both fingers move apart
403 /// symmetrically (spread doubles), so `zoom_delta` should equal the
404 /// spread ratio and rotation should stay ~0.
405 #[test]
406 fn pinch_out_reports_spread_ratio() {
407 let mut ts = TouchState::new();
408 // Baseline: fingers 100px apart, centroid at (150,100).
409 ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
410 ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
411 ts.update_gesture(); // latches baseline, zeroed deltas
412
413 // Spread to 200px apart around the same centroid.
414 ts.on_move(DEV, TouchId(0), Point::new(50.0, 100.0), None);
415 ts.on_move(DEV, TouchId(1), Point::new(250.0, 100.0), None);
416 ts.update_gesture();
417
418 let info = ts.current().expect("gesture present");
419 // Each finger's distance from the centroid went 50 -> 100, so the
420 // per-finger ratio (and hence the average) is exactly 2.0.
421 assert!(
422 (info.zoom_delta - 2.0).abs() < 1e-3,
423 "zoom_delta = {} (expected ~2.0)",
424 info.zoom_delta
425 );
426 assert!(
427 info.rotation_delta.abs() < 1e-3,
428 "pure pinch must not rotate, got {}",
429 info.rotation_delta
430 );
431 }
432
433 /// Pure rotation: both fingers rotate 30° CCW (Y-up) around a fixed
434 /// centroid at constant radius. The code computes
435 /// `atan2(dy,dx) - atan2(pdy,pdx)` on the raw coordinates, so a CCW
436 /// step in Y-up space yields a POSITIVE `rotation_delta` — matching the
437 /// documented "positive = CCW in Y-up" convention. Zoom must stay ~1.
438 #[test]
439 fn pure_rotation_reports_signed_angle_ccw_positive() {
440 let mut ts = TouchState::new();
441 // Fingers on a vertical line through centroid (100,100), r = 100.
442 // Vertical orientation keeps both fingers away from the atan2 ±pi
443 // seam so no per-finger wrap corrupts the average.
444 ts.on_start(DEV, TouchId(0), Point::new(100.0, 200.0), None); // angle +90°
445 ts.on_start(DEV, TouchId(1), Point::new(100.0, 0.0), None); // angle -90°
446 ts.update_gesture(); // baseline
447
448 // Rotate both +30° CCW (Y-up) about the centroid.
449 // finger 0: 90° -> 120° => (50, 186.60254)
450 // finger 1: -90° -> -60° => (150, 13.39746)
451 ts.on_move(DEV, TouchId(0), Point::new(50.0, 186.602_54), None);
452 ts.on_move(DEV, TouchId(1), Point::new(150.0, 13.397_46), None);
453 ts.update_gesture();
454
455 let info = ts.current().expect("gesture present");
456 let expected = 30.0_f32.to_radians();
457 assert!(
458 (info.rotation_delta - expected).abs() < 1e-3,
459 "rotation_delta = {} (expected ~+{} rad, +30° CCW)",
460 info.rotation_delta,
461 expected
462 );
463 assert!(
464 (info.zoom_delta - 1.0).abs() < 1e-3,
465 "pure rotation must not zoom, got {}",
466 info.zoom_delta
467 );
468 }
469
470 /// Pure translation: both fingers shift by the same offset, so the
471 /// centroid moves by exactly that offset while the per-finger geometry
472 /// is unchanged (zoom ~1, rotation ~0).
473 #[test]
474 fn pure_translation_reports_centroid_offset() {
475 let mut ts = TouchState::new();
476 ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
477 ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
478 ts.update_gesture(); // baseline
479
480 // Shift both by (+10, +20).
481 ts.on_move(DEV, TouchId(0), Point::new(110.0, 120.0), None);
482 ts.on_move(DEV, TouchId(1), Point::new(210.0, 120.0), None);
483 ts.update_gesture();
484
485 let info = ts.current().expect("gesture present");
486 assert!(
487 (info.translation_delta.x - 10.0).abs() < 1e-3
488 && (info.translation_delta.y - 20.0).abs() < 1e-3,
489 "translation_delta = ({},{}) (expected ~(10,20))",
490 info.translation_delta.x,
491 info.translation_delta.y
492 );
493 assert!(
494 (info.zoom_delta - 1.0).abs() < 1e-3,
495 "pure translation must not zoom, got {}",
496 info.zoom_delta
497 );
498 assert!(
499 info.rotation_delta.abs() < 1e-3,
500 "pure translation must not rotate, got {}",
501 info.rotation_delta
502 );
503 }
504
505 /// Lifting one finger drops below the two-finger minimum: `current()`
506 /// must become `None` and `active_count` must reflect the remaining
507 /// finger.
508 #[test]
509 fn finger_lift_clears_gesture() {
510 let mut ts = TouchState::new();
511 ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
512 ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
513 ts.update_gesture();
514 assert!(ts.current().is_some(), "two fingers -> gesture");
515 assert_eq!(ts.active_count(), 2);
516
517 ts.on_end_or_cancel(DEV, TouchId(1));
518 assert!(
519 ts.current().is_none(),
520 "one finger left -> no multi-touch gesture"
521 );
522 assert_eq!(ts.active_count(), 1);
523 }
524
525 /// A third finger arriving mid-gesture flags a topology change, so the
526 /// very next `update_gesture` must emit zeroed deltas (no spurious jump)
527 /// even though the centroid/geometry shifted as the finger landed.
528 #[test]
529 fn third_finger_join_resets_deltas() {
530 let mut ts = TouchState::new();
531 ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
532 ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
533 ts.update_gesture(); // baseline
534
535 // Establish a real (non-zero) gesture first.
536 ts.on_move(DEV, TouchId(0), Point::new(50.0, 100.0), None);
537 ts.on_move(DEV, TouchId(1), Point::new(250.0, 100.0), None);
538 ts.update_gesture();
539 let mid = ts.current().expect("gesture present");
540 assert!(mid.zoom_delta > 1.5, "sanity: mid-gesture was a pinch-out");
541
542 // Third finger lands off-centre; next frame must be zeroed.
543 ts.on_start(DEV, TouchId(2), Point::new(150.0, 300.0), None);
544 ts.update_gesture();
545 let info = ts.current().expect("three fingers -> gesture");
546 assert_eq!(info.num_touches, 3);
547 assert_eq!(info.zoom_delta, 1.0, "topology reset must zero zoom");
548 assert_eq!(info.rotation_delta, 0.0, "topology reset must zero rotation");
549 assert_eq!(info.translation_delta.x, 0.0);
550 assert_eq!(info.translation_delta.y, 0.0);
551 }
552
553 /// Regression: one finger crosses the atan2 ±pi seam during a small
554 /// real two-finger twist. Both fingers rotate +4° CCW around a fixed
555 /// centroid at (200,200), but finger A sits at 178° and steps to 182°
556 /// — crossing the seam so its raw per-frame delta reads as ≈ -356°.
557 /// Finger B (at -2° -> +2°) reads a clean +4°. Before the fix the sum
558 /// was normalised only AFTER averaging, so the frame delta collapsed to
559 /// ≈ -176° instead of +4°. Per-finger normalisation must repair this.
560 #[test]
561 fn seam_crossing_finger_does_not_flip_rotation() {
562 let mut ts = TouchState::new();
563 // Baseline: A at 178°, B at -2°, radius 100 about centroid (200,200).
564 ts.on_start(DEV, TouchId(0), Point::new(100.060917, 203.489950), None); // A: 178°
565 ts.on_start(DEV, TouchId(1), Point::new(299.939083, 196.510050), None); // B: -2°
566 ts.update_gesture(); // baseline (zeroed deltas)
567
568 // Both fingers step +4° CCW: A 178°->182° (crosses +pi seam),
569 // B -2°->+2°.
570 ts.on_move(DEV, TouchId(0), Point::new(100.060917, 196.510050), None); // A: 182°
571 ts.on_move(DEV, TouchId(1), Point::new(299.939083, 203.489950), None); // B: +2°
572 ts.update_gesture();
573
574 let info = ts.current().expect("gesture present");
575 let expected = 4.0_f32.to_radians();
576 assert!(
577 (info.rotation_delta - expected).abs() < 1e-3,
578 "rotation_delta = {} (expected ~+{} rad, +4° CCW); a seam-crossing \
579 finger must not flip the averaged rotation",
580 info.rotation_delta,
581 expected
582 );
583 assert!(
584 (info.zoom_delta - 1.0).abs() < 1e-3,
585 "pure rotation must not zoom, got {}",
586 info.zoom_delta
587 );
588 }
589
590 /// Two `update_gesture` calls with no movement in between: the second
591 /// frame (topology already cleared) must read as no-op deltas because
592 /// each finger's current position equals its latched baseline.
593 #[test]
594 fn no_movement_yields_identity_deltas() {
595 let mut ts = TouchState::new();
596 ts.on_start(DEV, TouchId(0), Point::new(100.0, 100.0), None);
597 ts.on_start(DEV, TouchId(1), Point::new(200.0, 100.0), None);
598 ts.update_gesture(); // baseline (topology_changed frame)
599 ts.update_gesture(); // steady state, no on_move between
600
601 let info = ts.current().expect("gesture present");
602 assert!(
603 (info.zoom_delta - 1.0).abs() < 1e-6,
604 "no movement -> zoom 1.0, got {}",
605 info.zoom_delta
606 );
607 assert!(
608 info.rotation_delta.abs() < 1e-6,
609 "no movement -> rotation 0, got {}",
610 info.rotation_delta
611 );
612 assert_eq!(info.translation_delta.x, 0.0);
613 assert_eq!(info.translation_delta.y, 0.0);
614 }
615}