Skip to main content

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}