Skip to main content

gpui/
gestures.rs

1//! Touch gesture recognition vocabulary.
2//!
3//! GPUI recognizes gestures from raw [`TouchEvent`](crate::TouchEvent)s in a
4//! single, portable arena in gpui core: recognizers compete for in-flight
5//! touches, winners claim them, and losers are cancelled. Recognized gestures
6//! are surfaced through *existing* semantic events wherever possible, a tap
7//! becomes [`ClickEvent::Touch`](crate::ClickEvent), a pan becomes
8//! [`ScrollWheelEvent`](crate::ScrollWheelEvent)s carrying a
9//! [`TouchPhase`](crate::TouchPhase), and a pinch becomes
10//! [`PinchEvent`](crate::PinchEvent)s — so components written against
11//! `on_click` and scroll containers work untouched on mobile.
12
13use std::collections::VecDeque;
14use std::mem;
15use std::time::Duration;
16
17use scheduler::Instant;
18use smallvec::SmallVec;
19
20use crate::{
21    Axis, GestureEvent, InputEvent, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseEvent,
22    MouseUpEvent, Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, TouchEvent, TouchId,
23    TouchPhase, point, px, seal::Sealed,
24};
25
26const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
27
28fn dominant_axis(delta: Point<Pixels>) -> Axis {
29    if delta.x.abs() <= delta.y.abs() {
30        Axis::Vertical
31    } else {
32        Axis::Horizontal
33    }
34}
35
36fn lock_delta_to_axis(delta: &mut Point<Pixels>, axis: Axis) {
37    match axis {
38        Axis::Vertical => delta.x = Pixels::ZERO,
39        Axis::Horizontal => delta.y = Pixels::ZERO,
40    }
41}
42
43fn movements_oppose(left: Point<Pixels>, right: Point<Pixels>) -> bool {
44    f32::from(left.x) * f32::from(right.x) + f32::from(left.y) * f32::from(right.y) < 0.
45}
46
47/// Tracks the dominant axis across the events in a scroll gesture.
48#[derive(Clone, Copy, Debug, Default)]
49pub struct OngoingScroll {
50    last_event: Option<Instant>,
51    axis: Option<Axis>,
52}
53
54impl OngoingScroll {
55    /// Filters the given delta to the dominant axis of the current scroll gesture.
56    ///
57    /// Gestures are delimited by their touch phase when available, with a timeout
58    /// fallback for platforms that only emit [`TouchPhase::Moved`].
59    pub fn filter(&mut self, delta: &mut Point<Pixels>, touch_phase: TouchPhase) {
60        self.filter_at(delta, touch_phase, Instant::now())
61    }
62
63    fn filter_at(&mut self, delta: &mut Point<Pixels>, touch_phase: TouchPhase, now: Instant) {
64        const UNLOCK_PERCENT: f32 = 1.9;
65        const UNLOCK_LOWER_BOUND: Pixels = px(6.);
66
67        if matches!(touch_phase, TouchPhase::Ended | TouchPhase::Cancelled) {
68            self.last_event = None;
69            self.axis = None;
70            return;
71        }
72
73        let x = delta.x.abs();
74        let y = delta.y.abs();
75        if x.is_zero() && y.is_zero() {
76            if touch_phase == TouchPhase::Started {
77                self.last_event = None;
78                self.axis = None;
79            }
80            return;
81        }
82
83        let starts_new_gesture = touch_phase == TouchPhase::Started
84            || self
85                .last_event
86                .is_none_or(|last_event| now.duration_since(last_event) >= SCROLL_EVENT_SEPARATION);
87        let mut axis = self.axis;
88        if starts_new_gesture {
89            axis = Some(dominant_axis(*delta));
90        } else if x.max(y) >= UNLOCK_LOWER_BOUND {
91            match axis {
92                Some(Axis::Vertical) if x > y && x >= y * UNLOCK_PERCENT => {
93                    axis = None;
94                }
95                Some(Axis::Horizontal) if y > x && y >= x * UNLOCK_PERCENT => {
96                    axis = None;
97                }
98                _ => {}
99            }
100        }
101
102        self.last_event = Some(now);
103        self.axis = axis;
104        if let Some(axis) = axis {
105            lock_delta_to_axis(delta, axis);
106        }
107    }
108}
109
110/// Feel constants consumed by gesture recognizers. Provided on a best-effort
111/// basis, depending on each platform's support, defaulting to GPUI's own
112/// (iOS flavored) values
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub struct GestureTuning {
115    /// Distance a touch may travel before it stops being a potential tap and
116    /// becomes a pan/drag.
117    pub touch_slop: Pixels,
118    /// Maximum interval between taps for them to accumulate a tap count.
119    pub multi_tap_interval: Duration,
120    /// Maximum distance between taps for them to accumulate a tap count.
121    pub multi_tap_slop: Pixels,
122    /// How long a touch must remain within [`Self::touch_slop`] to be
123    /// recognized as a long press.
124    pub long_press_duration: Duration,
125    /// How scroll momentum decelerates after a fling.
126    pub scroll_physics: ScrollPhysics,
127    /// Minimum release velocity, in pixels per second, required to start
128    /// scroll momentum.
129    pub min_fling_velocity: f32,
130}
131
132impl Default for GestureTuning {
133    fn default() -> Self {
134        Self {
135            touch_slop: px(8.),
136            multi_tap_interval: Duration::from_millis(400),
137            multi_tap_slop: px(16.),
138            long_press_duration: Duration::from_millis(500),
139            scroll_physics: ScrollPhysics::ios(),
140            min_fling_velocity: 50.,
141        }
142    }
143}
144
145/// How free scrolling decelerates after a fling.
146///
147/// This models deceleration only. Boundary behavior — bouncing, edge glow,
148/// clamping — is the scroll container's policy: the container is the one that
149/// knows its extents.
150#[derive(Clone, Copy, Debug, PartialEq)]
151pub enum ScrollPhysics {
152    /// Exponential velocity decay, the `UIScrollView` model:
153    /// `velocity(t) = v₀ · decay_per_msᵐˢ`.
154    Exponential {
155        /// Per-millisecond velocity decay factor. `UIScrollView`'s normal
156        /// deceleration rate is `0.998`.
157        decay_per_ms: f32,
158    },
159    /// The friction spline of Android's `OverScroller`: fling duration and
160    /// distance follow a logarithmic deceleration law, and progress along
161    /// the fling follows a cubic-Bezier ease-out curve. Transcribed from
162    /// AOSP's `SplineOverScroller` (Apache-2.0).
163    FrictionSpline {
164        /// The scroll friction coefficient;
165        /// `ViewConfiguration.getScrollFriction()` is `0.015` on Android.
166        friction: f32,
167        /// Pixels per physical inch of the display, in the coordinate space
168        /// the fling runs in. Android folds display density into its
169        /// deceleration coefficient, so the same finger speed flings
170        /// further in pixels on a denser screen.
171        pixels_per_inch: f32,
172    },
173}
174
175impl ScrollPhysics {
176    /// iOS scroll feel: `UIScrollView`'s normal deceleration rate.
177    pub fn ios() -> Self {
178        Self::Exponential {
179            decay_per_ms: 0.998,
180        }
181    }
182
183    /// Android scroll feel: `OverScroller` with stock friction, at Android's
184    /// nominal density of 160 density-independent pixels per inch — the
185    /// right pairing when fling distances are in logical pixels. Platforms
186    /// that fling in physical pixels, or know the display's true density in
187    /// their logical space, should construct
188    /// [`ScrollPhysics::FrictionSpline`] directly.
189    pub fn android() -> Self {
190        Self::FrictionSpline {
191            friction: 0.015,
192            pixels_per_inch: 160.,
193        }
194    }
195
196    /// How long a fling released at `speed` pixels per second coasts before
197    /// it stops.
198    fn fling_duration(self, speed: f32) -> Duration {
199        match self {
200            Self::Exponential { decay_per_ms } => {
201                if speed <= MOMENTUM_STOP_VELOCITY {
202                    return Duration::ZERO;
203                }
204                let milliseconds = (MOMENTUM_STOP_VELOCITY / speed).ln() / decay_per_ms.ln();
205                Duration::from_secs_f32(milliseconds / 1000.)
206            }
207            Self::FrictionSpline {
208                friction,
209                pixels_per_inch,
210            } => {
211                if speed <= 0. {
212                    return Duration::ZERO;
213                }
214                let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch);
215                let seconds = (deceleration / (friction_spline::deceleration_rate() - 1.)).exp();
216                Duration::from_secs_f64(seconds)
217            }
218        }
219    }
220
221    /// Distance traveled `elapsed` into a fling released at `speed` pixels
222    /// per second, in pixels along the fling direction. Evaluated in closed
223    /// form so the trajectory is independent of tick timing.
224    fn fling_distance(self, speed: f32, elapsed: Duration) -> f32 {
225        let duration = self.fling_duration(speed);
226        if duration.is_zero() {
227            return 0.;
228        }
229        let elapsed = elapsed.min(duration);
230        match self {
231            Self::Exponential { decay_per_ms } => {
232                // ∫₀ᵗ v₀·kᵐˢ dms, with speed converted to pixels per
233                // millisecond.
234                let milliseconds = elapsed.as_secs_f32() * 1000.;
235                (speed / 1000.) * (decay_per_ms.powf(milliseconds) - 1.) / decay_per_ms.ln()
236            }
237            Self::FrictionSpline {
238                friction,
239                pixels_per_inch,
240            } => {
241                let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch);
242                let rate = friction_spline::deceleration_rate();
243                let total_distance = friction as f64
244                    * friction_spline::physical_coefficient(pixels_per_inch)
245                    * (rate / (rate - 1.) * deceleration).exp();
246                let progress = elapsed.as_secs_f64() / duration.as_secs_f64();
247                total_distance as f32 * friction_spline::distance_coefficient(progress as f32)
248            }
249        }
250    }
251}
252
253/// The fling model of Android's `OverScroller.SplineOverScroller`,
254/// transcribed from AOSP (Apache-2.0). `SPLINE_TIME`, which AOSP uses for
255/// programmatic scroll animations rather than flings, is intentionally not
256/// transcribed.
257mod friction_spline {
258    use std::sync::LazyLock;
259
260    const NB_SAMPLES: usize = 100;
261    const INFLEXION: f32 = 0.35;
262    const START_TENSION: f32 = 0.5;
263    const END_TENSION: f32 = 1.0;
264    const P1: f32 = START_TENSION * INFLEXION;
265    const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
266
267    /// Android's `DECELERATION_RATE`: `ln(0.78) / ln(0.9)`.
268    pub(super) fn deceleration_rate() -> f64 {
269        0.78f64.ln() / 0.9f64.ln()
270    }
271
272    /// `SPLINE_POSITION` from AOSP's static initializer: fractional fling
273    /// distance sampled at 100 evenly spaced fractions of the fling
274    /// duration, from a cubic Bezier with control points shaped by
275    /// `INFLEXION` and the start/end tensions.
276    static SPLINE_POSITION: LazyLock<[f32; NB_SAMPLES + 1]> = LazyLock::new(|| {
277        let mut spline_position = [0f32; NB_SAMPLES + 1];
278        let mut x_min = 0f32;
279        for (i, sample) in spline_position.iter_mut().take(NB_SAMPLES).enumerate() {
280            let alpha = i as f32 / NB_SAMPLES as f32;
281            let mut x_max = 1f32;
282            let (x, coefficient) = loop {
283                let x = x_min + (x_max - x_min) / 2.;
284                let coefficient = 3. * x * (1. - x);
285                let time = coefficient * ((1. - x) * P1 + x * P2) + x * x * x;
286                if (time - alpha).abs() < 1e-5 {
287                    break (x, coefficient);
288                }
289                if time > alpha {
290                    x_max = x;
291                } else {
292                    x_min = x;
293                }
294            };
295            *sample = coefficient * ((1. - x) * START_TENSION + x) + x * x * x;
296        }
297        spline_position[NB_SAMPLES] = 1.;
298        spline_position
299    });
300
301    /// `SensorManager.GRAVITY_EARTH · 39.37 in/m · ppi · 0.84`, AOSP's
302    /// `mPhysicalCoeff`: gravity expressed in pixels, times an empirical
303    /// "look and feel" tuning factor.
304    pub(super) fn physical_coefficient(pixels_per_inch: f32) -> f64 {
305        9.80665 * 39.37 * pixels_per_inch as f64 * 0.84
306    }
307
308    /// AOSP's `getSplineDeceleration`.
309    pub(super) fn deceleration(speed: f32, friction: f32, pixels_per_inch: f32) -> f64 {
310        (INFLEXION as f64 * speed as f64
311            / (friction as f64 * physical_coefficient(pixels_per_inch)))
312        .ln()
313    }
314
315    /// Fraction of the total fling distance covered at fraction `time` of
316    /// the fling duration: table lookup plus linear interpolation, as in
317    /// `SplineOverScroller.update`.
318    pub(super) fn distance_coefficient(time: f32) -> f32 {
319        if time >= 1. {
320            return 1.;
321        }
322        let index = ((NB_SAMPLES as f32 * time) as usize).min(NB_SAMPLES - 1);
323        let time_lower = index as f32 / NB_SAMPLES as f32;
324        let time_upper = (index + 1) as f32 / NB_SAMPLES as f32;
325        let distance_lower = SPLINE_POSITION[index];
326        let distance_upper = SPLINE_POSITION[index + 1];
327        let velocity_coefficient = (distance_upper - distance_lower) / (time_upper - time_lower);
328        distance_lower + (time - time_lower) * velocity_coefficient
329    }
330
331    #[cfg(test)]
332    pub(super) fn bezier_time_and_position(parameter: f32) -> (f32, f32) {
333        let coefficient = 3. * parameter * (1. - parameter);
334        let cubed = parameter * parameter * parameter;
335        (
336            coefficient * ((1. - parameter) * P1 + parameter * P2) + cubed,
337            coefficient * ((1. - parameter) * START_TENSION + parameter) + cubed,
338        )
339    }
340
341    #[cfg(test)]
342    pub(super) fn spline_position_samples() -> &'static [f32; NB_SAMPLES + 1] {
343        &SPLINE_POSITION
344    }
345}
346
347/// The set of gesture kinds that participate in recognition.
348///
349/// Used by [`PlatformGestures::native_recognizers`] to declare which gestures
350/// the platform recognizes natively rather than leaving to gpui core's
351/// portable recognizers.
352#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
353pub struct GestureKinds {
354    /// Tap (and multi-tap), surfaced as [`ClickEvent::Touch`](crate::ClickEvent).
355    pub tap: bool,
356    /// Long press, surfaced as [`LongPressEvent`].
357    pub long_press: bool,
358    /// Pan/scroll (including fling momentum), surfaced as
359    /// [`ScrollWheelEvent`](crate::ScrollWheelEvent)s.
360    pub pan: bool,
361    /// Pinch to zoom, surfaced as [`PinchEvent`](crate::PinchEvent)s.
362    pub pinch: bool,
363}
364
365impl GestureKinds {
366    /// No gestures; gpui core's portable recognizers handle everything.
367    pub const NONE: Self = Self {
368        tap: false,
369        long_press: false,
370        pan: false,
371        pinch: false,
372    };
373
374    /// All gesture kinds.
375    pub const ALL: Self = Self {
376        tap: true,
377        long_press: true,
378        pan: true,
379        pinch: true,
380    };
381}
382
383/// A direct touch drag claimed by an element before touch input becomes a tap,
384/// long press, or scrolling gesture.
385#[derive(Clone, Debug)]
386pub struct TouchDragEvent {
387    /// The phase of the touch drag.
388    pub phase: TouchPhase,
389    /// The position where the touch started.
390    pub start_position: Point<Pixels>,
391    /// The touch's current position.
392    pub position: Point<Pixels>,
393}
394
395impl Sealed for TouchDragEvent {}
396impl InputEvent for TouchDragEvent {
397    fn to_platform_input(self) -> PlatformInput {
398        PlatformInput::TouchDrag(self)
399    }
400}
401impl GestureEvent for TouchDragEvent {}
402impl MouseEvent for TouchDragEvent {}
403
404/// A phased long-press gesture recognized from a touch.
405#[derive(Clone, Debug)]
406pub struct LongPressEvent {
407    /// The phase of the long press.
408    pub phase: TouchPhase,
409    /// The position where the touch started.
410    pub start_position: Point<Pixels>,
411    /// The touch's current position.
412    pub position: Point<Pixels>,
413}
414
415impl Default for LongPressEvent {
416    fn default() -> Self {
417        Self {
418            phase: TouchPhase::Started,
419            start_position: Point::default(),
420            position: Point::default(),
421        }
422    }
423}
424
425impl Sealed for LongPressEvent {}
426impl InputEvent for LongPressEvent {
427    fn to_platform_input(self) -> PlatformInput {
428        PlatformInput::LongPress(self)
429    }
430}
431impl GestureEvent for LongPressEvent {}
432impl MouseEvent for LongPressEvent {}
433
434/// Platform gesture recognition services.
435///
436/// If your mobile platform supports native gesture recognition, use this
437/// to share it with GPUI.
438pub trait PlatformGestures {
439    /// Feel constants for the portable recognizers on this platform.
440    fn tuning(&self) -> GestureTuning {
441        GestureTuning::default()
442    }
443
444    /// The gesture kinds this platform recognizes natively.
445    fn native_recognizers(&self) -> GestureKinds {
446        GestureKinds::NONE
447    }
448}
449
450/// A no-op [`PlatformGestures`] implementation: no native recognizers and
451/// default tuning. Suitable for desktop platforms and tests.
452pub struct NullPlatformGestures;
453
454impl PlatformGestures for NullPlatformGestures {}
455
456/// Ceiling on recognized fling velocity, in pixels per second (matches
457/// Flutter's `kMaxFlingVelocity`).
458const MAX_FLING_VELOCITY: f32 = 8000.;
459
460/// Momentum below this speed, in pixels per second, is imperceptible. The
461/// exponential model, which never mathematically stops, treats reaching this
462/// speed as the end of the fling. (The friction spline has a finite duration
463/// of its own.)
464const MOMENTUM_STOP_VELOCITY: f32 = 10.;
465
466/// How far back the release-velocity estimate looks. Samples older than this
467/// reflect an earlier part of the gesture, not the speed at release.
468const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
469
470/// A pause between samples longer than this means the finger stopped:
471/// anything before the pause describes an earlier motion, not the release
472/// (Flutter's `kAssumePointerMoveStoppedMilliseconds`). Touch hardware
473/// reports movement every 8–16ms while the finger is in motion.
474const VELOCITY_ASSUME_STOPPED_GAP: Duration = Duration::from_millis(40);
475
476const VELOCITY_MAX_SAMPLES: usize = 20;
477
478/// The portable recognizer behind raw touch input: it watches the
479/// [`TouchEvent`] stream for one touch at a time and resolves it into either
480/// a tap or a pan, following the competition model described in the module
481/// docs. Pans continue into post-release momentum when the touch lifts at
482/// speed; the window drives that phase through [`Self::tick_momentum`].
483///
484/// Taps are currently surfaced as synthesized mouse presses rather than
485/// [`ClickEvent::Touch`](crate::ClickEvent), which keeps every existing
486/// mouse-driven behavior (click listeners, caret placement, double-tap
487/// selection) working before elements grow a direct tap-delivery path.
488/// Pinch recognition is not implemented yet, and additional touches are ignored
489/// while one is being recognized.
490pub(crate) struct TouchGestureRecognizer {
491    tuning: GestureTuning,
492    state: TouchGestureState,
493    momentum: Option<Momentum>,
494    last_tap: Option<CompletedTap>,
495}
496
497/// A semantic event recognized from raw touches, ready to dispatch through
498/// the window's existing input paths.
499#[derive(Debug)]
500pub(crate) enum RecognizedTouchGesture {
501    /// One step of a pan (or of its post-release momentum), delivered to
502    /// scroll listeners at the pan's starting position.
503    Scroll(ScrollWheelEvent),
504    /// A recognized tap, delivered as a synthesized mouse press and release.
505    Tap {
506        down: MouseDownEvent,
507        up: MouseUpEvent,
508    },
509    TouchDrag(TouchDragEvent),
510    LongPress(LongPressEvent),
511}
512
513enum TouchGestureState {
514    Idle,
515    /// The touch is still within `touch_slop` of where it started: it can
516    /// still resolve into either a tap or a pan.
517    Pending {
518        touch: ActiveTouch,
519        deadline: Instant,
520        long_press_offered: bool,
521        touch_drag_offered: bool,
522    },
523    /// The touch exceeded `touch_slop`: it is a pan until it ends, and its
524    /// movement flows out as scroll events.
525    Panning {
526        touch: ActiveTouch,
527        axis: Axis,
528    },
529    LongPressing(ActiveTouch),
530    TouchDragging(ActiveTouch),
531}
532
533struct ActiveTouch {
534    id: TouchId,
535    start_position: Point<Pixels>,
536    /// The latest raw position reported for this touch.
537    last_position: Point<Pixels>,
538    /// The position pan output has scrolled to so far. While panning this
539    /// may run ahead of the raw touch by the event's predicted position;
540    /// the release event targets the raw position again, so the total
541    /// scrolled distance always converges to the finger's actual travel.
542    emitted_position: Point<Pixels>,
543    /// Retained across stationary samples so prediction corrections cannot
544    /// reverse a pan when integer browser coordinates repeat.
545    last_movement: Point<Pixels>,
546    velocity_tracker: VelocityTracker,
547}
548
549struct CompletedTap {
550    position: Point<Pixels>,
551    time: Instant,
552    count: usize,
553}
554
555/// One fling in progress. The trajectory is a closed-form curve of elapsed
556/// time — each tick evaluates it and emits the increment — so the fling is
557/// exactly frame-rate independent: a stalled frame simply resumes further
558/// along the same curve.
559struct Momentum {
560    /// Where the pan started; synthesized scroll events keep hit-testing
561    /// there so momentum stays with the container the gesture began on.
562    position: Point<Pixels>,
563    /// Unit vector of the release velocity.
564    direction: Point<f32>,
565    axis: Axis,
566    /// Release speed in pixels per second.
567    speed: f32,
568    started_at: Instant,
569    duration: Duration,
570    /// Distance already emitted along `direction`, in pixels.
571    emitted_distance: f32,
572}
573
574impl TouchGestureRecognizer {
575    pub(crate) fn new(tuning: GestureTuning) -> Self {
576        Self {
577            tuning,
578            state: TouchGestureState::Idle,
579            momentum: None,
580            last_tap: None,
581        }
582    }
583
584    pub(crate) fn handle_event(
585        &mut self,
586        event: &TouchEvent,
587    ) -> SmallVec<[RecognizedTouchGesture; 2]> {
588        self.handle_event_at(event, Instant::now())
589    }
590
591    fn handle_event_at(
592        &mut self,
593        event: &TouchEvent,
594        now: Instant,
595    ) -> SmallVec<[RecognizedTouchGesture; 2]> {
596        let mut recognized = SmallVec::new();
597        match event.phase {
598            TouchPhase::Started => {
599                let caught_fling = if let Some(momentum) = self.momentum.take() {
600                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
601                        momentum.position,
602                        Point::default(),
603                        TouchPhase::Ended,
604                    )));
605                    Some(momentum.axis)
606                } else {
607                    None
608                };
609                if matches!(self.state, TouchGestureState::Idle) {
610                    let mut velocity_tracker = VelocityTracker::default();
611                    velocity_tracker.push(now, event.position);
612                    let touch = ActiveTouch {
613                        id: event.id,
614                        start_position: event.position,
615                        last_position: event.position,
616                        emitted_position: event.position,
617                        last_movement: Point::default(),
618                        velocity_tracker,
619                    };
620                    if let Some(axis) = caught_fling {
621                        // A touch that catches a fling is a drag from the
622                        // first pixel: waiting out the slop would freeze the
623                        // content mid-scroll and then jump. It can also never
624                        // be a tap; releasing it just leaves the content
625                        // stopped, as on Android and iOS.
626                        recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
627                            touch.start_position,
628                            Point::default(),
629                            TouchPhase::Started,
630                        )));
631                        self.state = TouchGestureState::Panning { touch, axis };
632                    } else {
633                        self.state = TouchGestureState::Pending {
634                            touch,
635                            deadline: now + self.tuning.long_press_duration,
636                            long_press_offered: false,
637                            touch_drag_offered: false,
638                        };
639                    }
640                }
641            }
642            TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) {
643                TouchGestureState::Pending {
644                    mut touch,
645                    deadline,
646                    long_press_offered,
647                    touch_drag_offered,
648                } if touch.id == event.id => {
649                    touch.velocity_tracker.push(now, event.position);
650                    touch.last_position = event.position;
651                    let accumulated = event.position - touch.start_position;
652                    if accumulated.magnitude() > f64::from(self.tuning.touch_slop) {
653                        // Carry the full movement so far into the first scroll
654                        // step: the content catches up to the finger instead
655                        // of losing the slop distance.
656                        let mut target = event.predicted_position.unwrap_or(event.position);
657                        let axis = dominant_axis(accumulated);
658                        let mut delta = target - touch.start_position;
659                        lock_delta_to_axis(&mut delta, axis);
660                        touch.last_movement = accumulated;
661                        lock_delta_to_axis(&mut touch.last_movement, axis);
662                        if movements_oppose(delta, touch.last_movement) {
663                            target = event.position;
664                            delta = accumulated;
665                            lock_delta_to_axis(&mut delta, axis);
666                        }
667                        touch.emitted_position = target;
668                        recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
669                            touch.start_position,
670                            delta,
671                            TouchPhase::Started,
672                        )));
673                        self.state = TouchGestureState::Panning { touch, axis };
674                    } else {
675                        self.state = TouchGestureState::Pending {
676                            touch,
677                            deadline,
678                            long_press_offered,
679                            touch_drag_offered,
680                        };
681                    }
682                }
683                TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => {
684                    let mut raw_delta = event.position - touch.last_position;
685                    lock_delta_to_axis(&mut raw_delta, axis);
686                    if raw_delta != Point::default() {
687                        touch.last_movement = raw_delta;
688                    }
689                    touch.velocity_tracker.push(now, event.position);
690                    touch.last_position = event.position;
691                    let mut target = event.predicted_position.unwrap_or(event.position);
692                    let mut delta = target - touch.emitted_position;
693                    lock_delta_to_axis(&mut delta, axis);
694                    // Prediction error must not reverse content while the raw
695                    // touch still advances. Fall back to the raw position so a
696                    // real finger reversal remains responsive.
697                    if movements_oppose(delta, touch.last_movement) {
698                        target = event.position;
699                        delta = target - touch.emitted_position;
700                        lock_delta_to_axis(&mut delta, axis);
701                        if movements_oppose(delta, touch.last_movement) {
702                            target = touch.emitted_position;
703                            delta = Point::default();
704                        }
705                    }
706                    touch.emitted_position = target;
707                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
708                        touch.start_position,
709                        delta,
710                        TouchPhase::Moved,
711                    )));
712                    self.state = TouchGestureState::Panning { touch, axis };
713                }
714                TouchGestureState::LongPressing(mut touch) if touch.id == event.id => {
715                    touch.last_position = event.position;
716                    recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent {
717                        phase: TouchPhase::Moved,
718                        start_position: touch.start_position,
719                        position: event.position,
720                    }));
721                    self.state = TouchGestureState::LongPressing(touch);
722                }
723                TouchGestureState::TouchDragging(mut touch) if touch.id == event.id => {
724                    touch.last_position = event.position;
725                    recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
726                        phase: TouchPhase::Moved,
727                        start_position: touch.start_position,
728                        position: event.position,
729                    }));
730                    self.state = TouchGestureState::TouchDragging(touch);
731                }
732                other => self.state = other,
733            },
734            TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) {
735                TouchGestureState::Pending { touch, .. } if touch.id == event.id => {
736                    let tap_count = match &self.last_tap {
737                        Some(tap)
738                            if now.duration_since(tap.time) <= self.tuning.multi_tap_interval
739                                && (event.position - tap.position).magnitude()
740                                    <= f64::from(self.tuning.multi_tap_slop) =>
741                        {
742                            tap.count + 1
743                        }
744                        _ => 1,
745                    };
746                    self.last_tap = Some(CompletedTap {
747                        position: event.position,
748                        time: now,
749                        count: tap_count,
750                    });
751                    recognized.push(RecognizedTouchGesture::Tap {
752                        down: MouseDownEvent {
753                            button: MouseButton::Left,
754                            position: event.position,
755                            modifiers: Modifiers::default(),
756                            click_count: tap_count,
757                            first_mouse: false,
758                        },
759                        up: MouseUpEvent {
760                            button: MouseButton::Left,
761                            position: event.position,
762                            modifiers: Modifiers::default(),
763                            click_count: tap_count,
764                        },
765                    });
766                }
767                TouchGestureState::Panning { touch, axis } if touch.id == event.id => {
768                    // The release deliberately contributes no velocity
769                    // sample: it usually repeats the last movement's position
770                    // with a later timestamp, which would dilute the
771                    // estimate. But a release long after the last movement
772                    // means the finger had already stopped, so nothing
773                    // flings.
774                    let finger_stopped =
775                        touch
776                            .velocity_tracker
777                            .latest_sample_time()
778                            .is_none_or(|latest| {
779                                now.duration_since(latest) > VELOCITY_ASSUME_STOPPED_GAP
780                            });
781                    let mut velocity = if finger_stopped {
782                        Point::default()
783                    } else {
784                        touch.velocity_tracker.velocity()
785                    };
786                    match axis {
787                        Axis::Vertical => velocity.x = 0.,
788                        Axis::Horizontal => velocity.y = 0.,
789                    }
790                    let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt();
791                    let mut release_delta = event.position - touch.emitted_position;
792                    lock_delta_to_axis(&mut release_delta, axis);
793                    if speed >= self.tuning.min_fling_velocity {
794                        let direction = point(velocity.x / speed, velocity.y / speed);
795                        let speed = speed.min(MAX_FLING_VELOCITY);
796                        let duration = self.tuning.scroll_physics.fling_duration(speed);
797                        if !duration.is_zero() {
798                            let total_distance =
799                                self.tuning.scroll_physics.fling_distance(speed, duration);
800                            // Prediction may have left the content ahead of
801                            // the raw release position. Emitting that
802                            // correction here would visibly snap the content
803                            // backwards just as the fling launches, so fold
804                            // it into the fling instead: start the curve
805                            // already advanced by the overshoot, keeping the
806                            // total travel exact while staying monotonic.
807                            let overshoot = -(f32::from(release_delta.x) * direction.x
808                                + f32::from(release_delta.y) * direction.y);
809                            let emitted_distance = if overshoot > 0. && overshoot < total_distance {
810                                release_delta +=
811                                    point(px(direction.x * overshoot), px(direction.y * overshoot));
812                                overshoot
813                            } else {
814                                0.
815                            };
816                            self.momentum = Some(Momentum {
817                                position: touch.start_position,
818                                direction,
819                                axis,
820                                speed,
821                                started_at: now,
822                                duration,
823                                emitted_distance,
824                            });
825                        }
826                    }
827                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
828                        touch.start_position,
829                        release_delta,
830                        TouchPhase::Ended,
831                    )));
832                }
833                TouchGestureState::LongPressing(touch) if touch.id == event.id => {
834                    recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent {
835                        phase: TouchPhase::Ended,
836                        start_position: touch.start_position,
837                        position: event.position,
838                    }));
839                }
840                TouchGestureState::TouchDragging(touch) if touch.id == event.id => {
841                    recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
842                        phase: TouchPhase::Ended,
843                        start_position: touch.start_position,
844                        position: event.position,
845                    }));
846                }
847                other => self.state = other,
848            },
849            TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) {
850                TouchGestureState::Pending { touch, .. } if touch.id == event.id => {}
851                TouchGestureState::Panning { touch, .. } if touch.id == event.id => {
852                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
853                        touch.start_position,
854                        Point::default(),
855                        TouchPhase::Cancelled,
856                    )));
857                }
858                TouchGestureState::LongPressing(touch) if touch.id == event.id => {
859                    recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent {
860                        phase: TouchPhase::Cancelled,
861                        start_position: touch.start_position,
862                        position: event.position,
863                    }));
864                }
865                TouchGestureState::TouchDragging(touch) if touch.id == event.id => {
866                    recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
867                        phase: TouchPhase::Cancelled,
868                        start_position: touch.start_position,
869                        position: event.position,
870                    }));
871                }
872                other => self.state = other,
873            },
874        }
875        recognized
876    }
877
878    pub(crate) fn pending_long_press(&self) -> Option<(TouchId, Duration)> {
879        let TouchGestureState::Pending {
880            touch,
881            deadline,
882            long_press_offered: false,
883            ..
884        } = &self.state
885        else {
886            return None;
887        };
888        Some((touch.id, deadline.saturating_duration_since(Instant::now())))
889    }
890
891    pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option<RecognizedTouchGesture> {
892        let TouchGestureState::Pending {
893            touch,
894            long_press_offered,
895            ..
896        } = &mut self.state
897        else {
898            return None;
899        };
900        if touch.id != id || *long_press_offered {
901            return None;
902        }
903        *long_press_offered = true;
904        Some(RecognizedTouchGesture::LongPress(LongPressEvent {
905            phase: TouchPhase::Started,
906            start_position: touch.start_position,
907            position: touch.last_position,
908        }))
909    }
910
911    pub(crate) fn resolve_long_press(&mut self, claimed: bool) {
912        if !claimed {
913            return;
914        }
915        let state = mem::replace(&mut self.state, TouchGestureState::Idle);
916        self.state = match state {
917            TouchGestureState::Pending {
918                touch,
919                long_press_offered: true,
920                ..
921            } => TouchGestureState::LongPressing(touch),
922            other => other,
923        };
924    }
925
926    pub(crate) fn offer_touch_drag(&mut self, id: TouchId) -> Option<RecognizedTouchGesture> {
927        let TouchGestureState::Pending {
928            touch,
929            touch_drag_offered,
930            ..
931        } = &mut self.state
932        else {
933            return None;
934        };
935        if touch.id != id || *touch_drag_offered {
936            return None;
937        }
938        *touch_drag_offered = true;
939        Some(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
940            phase: TouchPhase::Started,
941            start_position: touch.start_position,
942            position: touch.last_position,
943        }))
944    }
945
946    pub(crate) fn resolve_touch_drag(&mut self, claimed: bool) {
947        if !claimed {
948            return;
949        }
950        let state = mem::replace(&mut self.state, TouchGestureState::Idle);
951        self.state = match state {
952            TouchGestureState::Pending {
953                touch,
954                touch_drag_offered: true,
955                ..
956            } => TouchGestureState::TouchDragging(touch),
957            other => other,
958        };
959    }
960
961    pub(crate) fn has_momentum(&self) -> bool {
962        self.momentum.is_some()
963    }
964
965    /// Advances post-fling momentum by one frame, returning the scroll step
966    /// to dispatch, or `None` when no momentum is in progress. The final step
967    /// carries [`TouchPhase::Ended`] to close the synthetic scroll stream.
968    pub(crate) fn tick_momentum(&mut self) -> Option<RecognizedTouchGesture> {
969        self.tick_momentum_at(Instant::now())
970    }
971
972    fn tick_momentum_at(&mut self, now: Instant) -> Option<RecognizedTouchGesture> {
973        let momentum = self.momentum.as_mut()?;
974        let elapsed = now.duration_since(momentum.started_at);
975        let distance = self
976            .tuning
977            .scroll_physics
978            .fling_distance(momentum.speed, elapsed);
979        // Prediction overshoot can start momentum ahead of its curve. Hold
980        // that position until the curve catches up instead of stepping back.
981        let step = (distance - momentum.emitted_distance).max(0.);
982        momentum.emitted_distance = momentum.emitted_distance.max(distance);
983        let delta = point(
984            px(momentum.direction.x * step),
985            px(momentum.direction.y * step),
986        );
987        let position = momentum.position;
988        if elapsed >= momentum.duration {
989            self.momentum = None;
990            Some(RecognizedTouchGesture::Scroll(scroll_event(
991                position,
992                delta,
993                TouchPhase::Ended,
994            )))
995        } else {
996            Some(RecognizedTouchGesture::Scroll(scroll_event(
997                position,
998                delta,
999                TouchPhase::Moved,
1000            )))
1001        }
1002    }
1003}
1004
1005fn scroll_event(
1006    position: Point<Pixels>,
1007    delta: Point<Pixels>,
1008    touch_phase: TouchPhase,
1009) -> ScrollWheelEvent {
1010    ScrollWheelEvent {
1011        position,
1012        delta: ScrollDelta::Pixels(delta),
1013        modifiers: Modifiers::default(),
1014        touch_phase,
1015    }
1016}
1017
1018/// Estimates the velocity a touch had at its newest sample.
1019#[derive(Default)]
1020struct VelocityTracker {
1021    samples: VecDeque<(Instant, Point<Pixels>)>,
1022}
1023
1024impl VelocityTracker {
1025    fn push(&mut self, time: Instant, position: Point<Pixels>) {
1026        self.samples.push_back((time, position));
1027        while self.samples.len() > VELOCITY_MAX_SAMPLES {
1028            self.samples.pop_front();
1029        }
1030    }
1031
1032    fn latest_sample_time(&self) -> Option<Instant> {
1033        self.samples.back().map(|(time, _)| *time)
1034    }
1035
1036    /// The velocity at the newest sample, in pixels per second.
1037    ///
1038    /// Fits a second-degree polynomial by least squares over the trailing
1039    /// [`VELOCITY_WINDOW`] and takes its derivative at the newest sample,
1040    /// like Flutter's `VelocityTracker` and Android's `lsq2` strategy. An
1041    /// endpoint difference over the same window would report the window's
1042    /// *average* speed, which for a flick — still accelerating at lift-off —
1043    /// is roughly half the speed the finger actually had at release.
1044    fn velocity(&self) -> Point<f32> {
1045        let Some((newest_time, _)) = self.samples.back() else {
1046            return Point::default();
1047        };
1048        let mut times_seconds: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new();
1049        let mut horizontal: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new();
1050        let mut vertical: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new();
1051        let mut previous_time = *newest_time;
1052        for (time, position) in self.samples.iter().rev() {
1053            let age = newest_time.duration_since(*time);
1054            if age > VELOCITY_WINDOW
1055                || previous_time.duration_since(*time) > VELOCITY_ASSUME_STOPPED_GAP
1056            {
1057                break;
1058            }
1059            previous_time = *time;
1060            times_seconds.push(-age.as_secs_f64());
1061            horizontal.push(f64::from(f32::from(position.x)));
1062            vertical.push(f64::from(f32::from(position.y)));
1063        }
1064
1065        let endpoint_estimate = |values: &[f64]| -> f32 {
1066            let elapsed = -times_seconds.last().copied().unwrap_or(0.);
1067            if elapsed <= f64::EPSILON {
1068                return 0.;
1069            }
1070            ((values.first().copied().unwrap_or(0.) - values.last().copied().unwrap_or(0.))
1071                / elapsed) as f32
1072        };
1073        if times_seconds.len() < 3 {
1074            return point(endpoint_estimate(&horizontal), endpoint_estimate(&vertical));
1075        }
1076        point(
1077            quadratic_velocity_at_newest(&times_seconds, &horizontal).map_or_else(
1078                || endpoint_estimate(&horizontal),
1079                |velocity| velocity as f32,
1080            ),
1081            quadratic_velocity_at_newest(&times_seconds, &vertical)
1082                .map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32),
1083        )
1084    }
1085}
1086
1087/// Least-squares fit of `value = a0 + a1·t + a2·t²` returning `a1`: the
1088/// fitted curve's velocity at `t = 0`, which callers place at the newest
1089/// sample. `None` when the samples are too degenerate to fit (all
1090/// simultaneous, for example).
1091fn quadratic_velocity_at_newest(times: &[f64], values: &[f64]) -> Option<f64> {
1092    let count = times.len() as f64;
1093    let (mut sum_t1, mut sum_t2, mut sum_t3, mut sum_t4) = (0., 0., 0., 0.);
1094    let (mut sum_v, mut sum_vt, mut sum_vt2) = (0., 0., 0.);
1095    for (&time, &value) in times.iter().zip(values) {
1096        let time_squared = time * time;
1097        sum_t1 += time;
1098        sum_t2 += time_squared;
1099        sum_t3 += time_squared * time;
1100        sum_t4 += time_squared * time_squared;
1101        sum_v += value;
1102        sum_vt += value * time;
1103        sum_vt2 += value * time_squared;
1104    }
1105    // Cramer's rule on the 3×3 normal equations, solved for the linear
1106    // coefficient only.
1107    let determinant = count * (sum_t2 * sum_t4 - sum_t3 * sum_t3)
1108        - sum_t1 * (sum_t1 * sum_t4 - sum_t3 * sum_t2)
1109        + sum_t2 * (sum_t1 * sum_t3 - sum_t2 * sum_t2);
1110    if determinant.abs() < 1e-12 {
1111        return None;
1112    }
1113    let linear_determinant = count * (sum_vt * sum_t4 - sum_t3 * sum_vt2)
1114        - sum_v * (sum_t1 * sum_t4 - sum_t3 * sum_t2)
1115        + sum_t2 * (sum_t1 * sum_vt2 - sum_vt * sum_t2);
1116    Some(linear_determinant / determinant)
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122    use crate::point;
1123
1124    #[test]
1125    fn ongoing_scroll_locks_to_dominant_axis() {
1126        let now = Instant::now();
1127        let mut ongoing_scroll = OngoingScroll::default();
1128        let mut horizontal_delta = point(px(10.), px(2.));
1129        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
1130        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
1131        assert_eq!(horizontal_delta, point(px(10.), px(0.)));
1132
1133        let mut continued_delta = point(px(3.), px(2.));
1134        ongoing_scroll.filter_at(
1135            &mut continued_delta,
1136            TouchPhase::Moved,
1137            now + Duration::from_millis(1),
1138        );
1139        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
1140        assert_eq!(continued_delta, point(px(3.), px(0.)));
1141    }
1142
1143    #[test]
1144    fn ongoing_scroll_unlocks_when_direction_changes() {
1145        let now = Instant::now();
1146        let mut ongoing_scroll = OngoingScroll::default();
1147        let mut horizontal_delta = point(px(10.), px(2.));
1148        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
1149
1150        let mut vertical_delta = point(px(2.), px(10.));
1151        ongoing_scroll.filter_at(
1152            &mut vertical_delta,
1153            TouchPhase::Moved,
1154            now + Duration::from_millis(1),
1155        );
1156        assert_eq!(ongoing_scroll.axis, None);
1157        assert_eq!(vertical_delta, point(px(2.), px(10.)));
1158    }
1159
1160    #[test]
1161    fn ongoing_scroll_starts_new_gesture_at_timeout_boundary() {
1162        let now = Instant::now();
1163        let mut ongoing_scroll = OngoingScroll::default();
1164        let mut horizontal_delta = point(px(10.), px(2.));
1165        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now);
1166
1167        let mut vertical_delta = point(px(2.), px(10.));
1168        ongoing_scroll.filter_at(
1169            &mut vertical_delta,
1170            TouchPhase::Moved,
1171            now + SCROLL_EVENT_SEPARATION,
1172        );
1173        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
1174        assert_eq!(vertical_delta, point(px(0.), px(10.)));
1175    }
1176
1177    #[test]
1178    fn ongoing_scroll_ignores_zero_delta_and_resets_when_ended() {
1179        let now = Instant::now();
1180        let mut ongoing_scroll = OngoingScroll::default();
1181        let mut horizontal_delta = point(px(10.), px(2.));
1182        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
1183
1184        let mut zero_delta = Point::default();
1185        ongoing_scroll.filter_at(
1186            &mut zero_delta,
1187            TouchPhase::Ended,
1188            now + Duration::from_millis(1),
1189        );
1190        assert_eq!(ongoing_scroll.axis, None);
1191
1192        let mut vertical_delta = point(px(2.), px(3.));
1193        ongoing_scroll.filter_at(
1194            &mut vertical_delta,
1195            TouchPhase::Moved,
1196            now + Duration::from_millis(2),
1197        );
1198        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
1199        assert_eq!(vertical_delta, point(px(0.), px(3.)));
1200    }
1201
1202    #[test]
1203    fn ongoing_scroll_ignores_zero_delta_movement() {
1204        let now = Instant::now();
1205        let mut ongoing_scroll = OngoingScroll::default();
1206        let mut horizontal_delta = point(px(10.), px(2.));
1207        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
1208
1209        let mut zero_delta = Point::default();
1210        ongoing_scroll.filter_at(
1211            &mut zero_delta,
1212            TouchPhase::Moved,
1213            now + SCROLL_EVENT_SEPARATION,
1214        );
1215
1216        let mut vertical_delta = point(px(2.), px(10.));
1217        ongoing_scroll.filter_at(
1218            &mut vertical_delta,
1219            TouchPhase::Moved,
1220            now + SCROLL_EVENT_SEPARATION,
1221        );
1222        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
1223        assert_eq!(vertical_delta, point(px(0.), px(10.)));
1224    }
1225
1226    #[test]
1227    fn ongoing_scroll_supports_moved_only_platforms() {
1228        let now = Instant::now();
1229        let mut ongoing_scroll = OngoingScroll::default();
1230        let mut horizontal_delta = point(px(10.), px(2.));
1231        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now);
1232        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
1233        assert_eq!(horizontal_delta, point(px(10.), px(0.)));
1234    }
1235
1236    #[test]
1237    fn touch_within_slop_resolves_to_tap() {
1238        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1239        let now = Instant::now();
1240        let touch = TouchId(1);
1241
1242        let recognized =
1243            recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 10.), now);
1244        assert!(recognized.is_empty());
1245        let recognized = recognizer.handle_event_at(
1246            &touch_event(touch, TouchPhase::Moved, 12., 11.),
1247            now + Duration::from_millis(20),
1248        );
1249        assert!(recognized.is_empty());
1250
1251        let recognized = recognizer.handle_event_at(
1252            &touch_event(touch, TouchPhase::Ended, 12., 11.),
1253            now + Duration::from_millis(60),
1254        );
1255        let [RecognizedTouchGesture::Tap { down, up }] = recognized.as_slice() else {
1256            panic!("expected tap, got {recognized:?}");
1257        };
1258        assert_eq!(down.click_count, 1);
1259        assert_eq!(down.position, point(px(12.), px(11.)));
1260        assert_eq!(up.click_count, 1);
1261    }
1262
1263    #[test]
1264    fn consecutive_taps_accumulate_tap_count() {
1265        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1266        let now = Instant::now();
1267
1268        recognizer.handle_event_at(&touch_event(TouchId(1), TouchPhase::Started, 10., 10.), now);
1269        recognizer.handle_event_at(
1270            &touch_event(TouchId(1), TouchPhase::Ended, 10., 10.),
1271            now + Duration::from_millis(40),
1272        );
1273
1274        let second_down = now + Duration::from_millis(200);
1275        recognizer.handle_event_at(
1276            &touch_event(TouchId(2), TouchPhase::Started, 14., 10.),
1277            second_down,
1278        );
1279        let recognized = recognizer.handle_event_at(
1280            &touch_event(TouchId(2), TouchPhase::Ended, 14., 10.),
1281            second_down + Duration::from_millis(40),
1282        );
1283        let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else {
1284            panic!("expected tap, got {recognized:?}");
1285        };
1286        assert_eq!(down.click_count, 2);
1287
1288        let late_down = second_down + Duration::from_secs(2);
1289        recognizer.handle_event_at(
1290            &touch_event(TouchId(3), TouchPhase::Started, 14., 10.),
1291            late_down,
1292        );
1293        let recognized = recognizer.handle_event_at(
1294            &touch_event(TouchId(3), TouchPhase::Ended, 14., 10.),
1295            late_down + Duration::from_millis(40),
1296        );
1297        let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else {
1298            panic!("expected tap, got {recognized:?}");
1299        };
1300        assert_eq!(down.click_count, 1);
1301    }
1302
1303    #[test]
1304    fn touch_beyond_slop_resolves_to_pan() {
1305        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1306        let now = Instant::now();
1307        let touch = TouchId(1);
1308
1309        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1310
1311        let recognized = recognizer.handle_event_at(
1312            &touch_event(touch, TouchPhase::Moved, 100., 120.),
1313            now + Duration::from_millis(16),
1314        );
1315        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1316            panic!("expected scroll, got {recognized:?}");
1317        };
1318        assert_eq!(scroll.touch_phase, TouchPhase::Started);
1319        assert_eq!(scroll.position, point(px(100.), px(100.)));
1320        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.)));
1321
1322        let recognized = recognizer.handle_event_at(
1323            &touch_event(touch, TouchPhase::Moved, 100., 135.),
1324            now + Duration::from_millis(32),
1325        );
1326        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1327            panic!("expected scroll, got {recognized:?}");
1328        };
1329        assert_eq!(scroll.touch_phase, TouchPhase::Moved);
1330        assert_eq!(scroll.position, point(px(100.), px(100.)));
1331        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(15.)));
1332
1333        let recognized = recognizer.handle_event_at(
1334            &touch_event(touch, TouchPhase::Ended, 100., 135.),
1335            now + Duration::from_millis(48),
1336        );
1337        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1338            panic!("expected scroll, got {recognized:?}");
1339        };
1340        assert_eq!(scroll.touch_phase, TouchPhase::Ended);
1341    }
1342
1343    #[test]
1344    fn touch_pan_stays_locked_to_its_initial_dominant_axis() {
1345        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1346        let now = Instant::now();
1347        let touch = TouchId(1);
1348
1349        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1350
1351        let recognized = recognizer.handle_event_at(
1352            &touch_event(touch, TouchPhase::Moved, 104., 120.),
1353            now + Duration::from_millis(16),
1354        );
1355        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1356            panic!("expected scroll, got {recognized:?}");
1357        };
1358        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.)));
1359
1360        let recognized = recognizer.handle_event_at(
1361            &touch_event(touch, TouchPhase::Moved, 134., 125.),
1362            now + Duration::from_millis(32),
1363        );
1364        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1365            panic!("expected scroll, got {recognized:?}");
1366        };
1367        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(5.)));
1368    }
1369
1370    #[test]
1371    fn touch_pan_locks_to_horizontal_axis() {
1372        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1373        let now = Instant::now();
1374        let touch = TouchId(1);
1375
1376        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1377
1378        let recognized = recognizer.handle_event_at(
1379            &touch_event(touch, TouchPhase::Moved, 120., 104.),
1380            now + Duration::from_millis(16),
1381        );
1382        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1383            panic!("expected scroll, got {recognized:?}");
1384        };
1385        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(20.), px(0.)));
1386    }
1387
1388    #[test]
1389    fn predicted_positions_lead_the_pan_but_totals_converge_on_release() {
1390        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1391        let now = Instant::now();
1392        let touch = TouchId(1);
1393
1394        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1395
1396        // The first pan step scrolls to the predicted position, not the raw one.
1397        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.);
1398        moved.predicted_position = Some(point(px(106.), px(128.)));
1399        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16));
1400        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1401            panic!("expected scroll, got {recognized:?}");
1402        };
1403        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(28.)));
1404
1405        // The next step is measured from where the previous prediction left
1406        // the content, so an overshoot is paid back here.
1407        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 130.);
1408        moved.predicted_position = Some(point(px(104.), px(134.)));
1409        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32));
1410        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1411            panic!("expected scroll, got {recognized:?}");
1412        };
1413        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.)));
1414
1415        // A release without a fling (the finger stopped long before lifting)
1416        // targets the raw position: the total scrolled distance equals the
1417        // finger's actual travel despite the predictions.
1418        let recognized = recognizer.handle_event_at(
1419            &touch_event(touch, TouchPhase::Ended, 100., 130.),
1420            now + Duration::from_millis(120),
1421        );
1422        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1423            panic!("expected scroll, got {recognized:?}");
1424        };
1425        assert_eq!(scroll.touch_phase, TouchPhase::Ended);
1426        assert!(!recognizer.has_momentum());
1427        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.)));
1428    }
1429
1430    #[test]
1431    fn predicted_positions_do_not_emit_false_reversals() {
1432        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1433        let now = Instant::now();
1434        let touch = TouchId(1);
1435
1436        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1437
1438        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.);
1439        moved.predicted_position = Some(point(px(100.), px(130.)));
1440        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16));
1441        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1442            panic!("expected scroll, got {recognized:?}");
1443        };
1444        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(30.)));
1445
1446        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.);
1447        moved.predicted_position = Some(point(px(100.), px(127.)));
1448        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32));
1449        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1450            panic!("expected scroll, got {recognized:?}");
1451        };
1452        assert_eq!(
1453            scroll.delta.pixel_delta(px(16.)),
1454            Point::<Pixels>::default()
1455        );
1456
1457        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.);
1458        moved.predicted_position = Some(point(px(100.), px(126.)));
1459        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(40));
1460        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1461            panic!("expected scroll, got {recognized:?}");
1462        };
1463        assert_eq!(
1464            scroll.delta.pixel_delta(px(16.)),
1465            Point::<Pixels>::default()
1466        );
1467
1468        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 132.);
1469        moved.predicted_position = Some(point(px(100.), px(136.)));
1470        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(48));
1471        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1472            panic!("expected scroll, got {recognized:?}");
1473        };
1474        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.)));
1475
1476        let mut moved = touch_event(touch, TouchPhase::Moved, 100., 124.);
1477        moved.predicted_position = Some(point(px(100.), px(140.)));
1478        let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(64));
1479        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1480            panic!("expected scroll, got {recognized:?}");
1481        };
1482        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-12.)));
1483    }
1484
1485    #[test]
1486    fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() {
1487        let now = Instant::now();
1488        let mut total_with_prediction = 0f32;
1489        let mut total_without_prediction = 0f32;
1490        for use_prediction in [true, false] {
1491            let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1492            let mut total = 0f32;
1493            let mut drain = |recognized: &[RecognizedTouchGesture], upward_only: bool| {
1494                for gesture in recognized {
1495                    let RecognizedTouchGesture::Scroll(scroll) = gesture else {
1496                        panic!("expected scroll, got {gesture:?}");
1497                    };
1498                    let delta = scroll.delta.pixel_delta(px(16.)).y;
1499                    if upward_only {
1500                        assert!(
1501                            delta <= px(0.),
1502                            "content moved backwards by {delta:?} during an upward gesture"
1503                        );
1504                    }
1505                    total += f32::from(delta);
1506                }
1507            };
1508
1509            recognizer.handle_event_at(
1510                &touch_event(TouchId(1), TouchPhase::Started, 100., 500.),
1511                now,
1512            );
1513            for step in 1..=5u64 {
1514                let raw_y = 500. - step as f32 * 40.;
1515                let mut moved = touch_event(TouchId(1), TouchPhase::Moved, 100., raw_y);
1516                if use_prediction {
1517                    moved.predicted_position = Some(point(px(100.), px(raw_y - 25.)));
1518                }
1519                let recognized =
1520                    recognizer.handle_event_at(&moved, now + Duration::from_millis(step * 16));
1521                drain(&recognized, use_prediction);
1522            }
1523            // The release leaves the emitted position 25px ahead of the raw
1524            // one; with prediction the correction must not scroll backwards.
1525            let recognized = recognizer.handle_event_at(
1526                &touch_event(TouchId(1), TouchPhase::Ended, 100., 300.),
1527                now + Duration::from_millis(90),
1528            );
1529            drain(&recognized, use_prediction);
1530            assert!(recognizer.has_momentum());
1531            let mut tick = now + Duration::from_millis(91);
1532            while recognizer.has_momentum() {
1533                if let Some(gesture) = recognizer.tick_momentum_at(tick) {
1534                    drain(&[gesture], use_prediction);
1535                }
1536                tick += Duration::from_millis(16);
1537            }
1538
1539            if use_prediction {
1540                total_with_prediction = total;
1541            } else {
1542                total_without_prediction = total;
1543            }
1544        }
1545        // Folding the overshoot into the fling redistributes the travel but
1546        // must not change where the content comes to rest.
1547        assert!(
1548            (total_with_prediction - total_without_prediction).abs() < 0.01,
1549            "totals diverged: {total_with_prediction} vs {total_without_prediction}"
1550        );
1551    }
1552
1553    #[test]
1554    fn fast_release_starts_momentum_that_decays_to_a_stop() {
1555        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1556        let now = Instant::now();
1557        let touch = TouchId(1);
1558
1559        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now);
1560        for step in 1..=5 {
1561            recognizer.handle_event_at(
1562                &touch_event(touch, TouchPhase::Moved, 100., 300. - step as f32 * 20.),
1563                now + Duration::from_millis(step * 16),
1564            );
1565        }
1566        recognizer.handle_event_at(
1567            &touch_event(touch, TouchPhase::Ended, 100., 200.),
1568            now + Duration::from_millis(6 * 16),
1569        );
1570        assert!(recognizer.has_momentum());
1571
1572        let tick = now + Duration::from_millis(6 * 16 + 16);
1573        let recognized = recognizer.tick_momentum_at(tick);
1574        let Some(RecognizedTouchGesture::Scroll(scroll)) = recognized else {
1575            panic!("expected momentum scroll, got {recognized:?}");
1576        };
1577        assert_eq!(scroll.touch_phase, TouchPhase::Moved);
1578        assert_eq!(scroll.position, point(px(100.), px(300.)));
1579        let delta = scroll.delta.pixel_delta(px(16.));
1580        assert!(
1581            delta.y < px(0.),
1582            "momentum should continue upward, got {delta:?}"
1583        );
1584        // The least-squares fit may leave float residue on the motionless axis.
1585        assert!(
1586            delta.x.abs() < px(0.001),
1587            "expected no x motion, got {delta:?}"
1588        );
1589
1590        let mut last_phase = TouchPhase::Moved;
1591        let mut ticks = 0;
1592        let mut time = tick;
1593        while recognizer.has_momentum() {
1594            time += Duration::from_millis(16);
1595            ticks += 1;
1596            assert!(ticks < 1000, "momentum never stopped");
1597            if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time)
1598            {
1599                last_phase = scroll.touch_phase;
1600            }
1601        }
1602        assert_eq!(last_phase, TouchPhase::Ended);
1603        assert!(recognizer.tick_momentum_at(time).is_none());
1604    }
1605
1606    #[test]
1607    fn diagonal_release_flings_only_on_locked_axis() {
1608        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1609        let now = Instant::now();
1610        let touch = TouchId(1);
1611
1612        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now);
1613        for step in 1..=5 {
1614            let recognized = recognizer.handle_event_at(
1615                &touch_event(
1616                    touch,
1617                    TouchPhase::Moved,
1618                    100. + step as f32 * 3.,
1619                    300. - step as f32 * 20.,
1620                ),
1621                now + Duration::from_millis(step * 16),
1622            );
1623            let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1624                panic!("expected scroll, got {recognized:?}");
1625            };
1626            assert_eq!(scroll.delta.pixel_delta(px(16.)).x, px(0.));
1627        }
1628        recognizer.handle_event_at(
1629            &touch_event(touch, TouchPhase::Ended, 115., 200.),
1630            now + Duration::from_millis(6 * 16),
1631        );
1632        assert!(recognizer.has_momentum());
1633
1634        let mut time = now + Duration::from_millis(6 * 16);
1635        while recognizer.has_momentum() {
1636            time += Duration::from_millis(16);
1637            if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time)
1638            {
1639                let delta = scroll.delta.pixel_delta(px(16.));
1640                assert_eq!(delta.x, px(0.));
1641                assert!(delta.y <= px(0.));
1642            }
1643        }
1644    }
1645
1646    #[test]
1647    fn slow_release_does_not_start_momentum() {
1648        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1649        let now = Instant::now();
1650        let touch = TouchId(1);
1651
1652        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now);
1653        recognizer.handle_event_at(
1654            &touch_event(touch, TouchPhase::Moved, 100., 280.),
1655            now + Duration::from_millis(16),
1656        );
1657        recognizer.handle_event_at(
1658            &touch_event(touch, TouchPhase::Moved, 100., 279.),
1659            now + Duration::from_millis(500),
1660        );
1661        recognizer.handle_event_at(
1662            &touch_event(touch, TouchPhase::Ended, 100., 279.),
1663            now + Duration::from_millis(600),
1664        );
1665        assert!(!recognizer.has_momentum());
1666    }
1667
1668    #[test]
1669    fn new_touch_interrupts_momentum() {
1670        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1671        let now = Instant::now();
1672
1673        recognizer.handle_event_at(
1674            &touch_event(TouchId(1), TouchPhase::Started, 100., 300.),
1675            now,
1676        );
1677        for step in 1..=3 {
1678            recognizer.handle_event_at(
1679                &touch_event(
1680                    TouchId(1),
1681                    TouchPhase::Moved,
1682                    100.,
1683                    300. - step as f32 * 33.,
1684                ),
1685                now + Duration::from_millis(step * 16),
1686            );
1687        }
1688        recognizer.handle_event_at(
1689            &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.),
1690            now + Duration::from_millis(64),
1691        );
1692        assert!(recognizer.has_momentum());
1693
1694        let recognized = recognizer.handle_event_at(
1695            &touch_event(TouchId(2), TouchPhase::Started, 100., 200.),
1696            now + Duration::from_millis(200),
1697        );
1698        assert!(!recognizer.has_momentum());
1699        let [
1700            RecognizedTouchGesture::Scroll(closing),
1701            RecognizedTouchGesture::Scroll(opening),
1702        ] = recognized.as_slice()
1703        else {
1704            panic!("expected closing and opening scrolls, got {recognized:?}");
1705        };
1706        assert_eq!(closing.touch_phase, TouchPhase::Ended);
1707        assert!(closing.delta.pixel_delta(px(16.)).is_zero());
1708        assert_eq!(opening.touch_phase, TouchPhase::Started);
1709        assert!(opening.delta.pixel_delta(px(16.)).is_zero());
1710    }
1711
1712    #[test]
1713    fn catching_a_fling_pans_immediately_and_never_taps() {
1714        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1715        let now = Instant::now();
1716
1717        recognizer.handle_event_at(
1718            &touch_event(TouchId(1), TouchPhase::Started, 100., 300.),
1719            now,
1720        );
1721        for step in 1..=3 {
1722            recognizer.handle_event_at(
1723                &touch_event(
1724                    TouchId(1),
1725                    TouchPhase::Moved,
1726                    100.,
1727                    300. - step as f32 * 33.,
1728                ),
1729                now + Duration::from_millis(step * 16),
1730            );
1731        }
1732        recognizer.handle_event_at(
1733            &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.),
1734            now + Duration::from_millis(64),
1735        );
1736        assert!(recognizer.has_momentum());
1737
1738        recognizer.handle_event_at(
1739            &touch_event(TouchId(2), TouchPhase::Started, 100., 200.),
1740            now + Duration::from_millis(200),
1741        );
1742
1743        // A movement well within the slop scrolls immediately.
1744        let recognized = recognizer.handle_event_at(
1745            &touch_event(TouchId(2), TouchPhase::Moved, 100., 197.),
1746            now + Duration::from_millis(216),
1747        );
1748        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1749            panic!("expected scroll, got {recognized:?}");
1750        };
1751        assert_eq!(scroll.touch_phase, TouchPhase::Moved);
1752        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-3.)));
1753
1754        // Releasing the catch is not a tap.
1755        let recognized = recognizer.handle_event_at(
1756            &touch_event(TouchId(2), TouchPhase::Ended, 100., 197.),
1757            now + Duration::from_millis(232),
1758        );
1759        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1760            panic!("expected scroll, got {recognized:?}");
1761        };
1762        assert_eq!(scroll.touch_phase, TouchPhase::Ended);
1763    }
1764
1765    #[test]
1766    fn cancelled_pan_emits_cancelled_scroll_and_no_tap() {
1767        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1768        let now = Instant::now();
1769        let touch = TouchId(1);
1770
1771        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1772        recognizer.handle_event_at(
1773            &touch_event(touch, TouchPhase::Moved, 100., 150.),
1774            now + Duration::from_millis(16),
1775        );
1776        let recognized = recognizer.handle_event_at(
1777            &touch_event(touch, TouchPhase::Cancelled, 100., 150.),
1778            now + Duration::from_millis(32),
1779        );
1780        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1781            panic!("expected cancelled scroll, got {recognized:?}");
1782        };
1783        assert_eq!(scroll.touch_phase, TouchPhase::Cancelled);
1784        assert!(!recognizer.has_momentum());
1785
1786        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1787        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
1788        let recognized = recognizer.handle_event_at(
1789            &touch_event(touch, TouchPhase::Cancelled, 100., 102.),
1790            now + Duration::from_millis(16),
1791        );
1792        assert!(recognized.is_empty(), "cancelled tap must not click");
1793    }
1794
1795    #[test]
1796    fn concurrent_touches_are_ignored_while_one_is_active() {
1797        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1798        let now = Instant::now();
1799
1800        recognizer.handle_event_at(
1801            &touch_event(TouchId(1), TouchPhase::Started, 100., 100.),
1802            now,
1803        );
1804        let recognized = recognizer.handle_event_at(
1805            &touch_event(TouchId(2), TouchPhase::Started, 200., 200.),
1806            now + Duration::from_millis(8),
1807        );
1808        assert!(recognized.is_empty());
1809        let recognized = recognizer.handle_event_at(
1810            &touch_event(TouchId(2), TouchPhase::Moved, 200., 300.),
1811            now + Duration::from_millis(16),
1812        );
1813        assert!(recognized.is_empty());
1814        let recognized = recognizer.handle_event_at(
1815            &touch_event(TouchId(2), TouchPhase::Ended, 200., 300.),
1816            now + Duration::from_millis(24),
1817        );
1818        assert!(recognized.is_empty());
1819
1820        // The first touch still resolves normally.
1821        let recognized = recognizer.handle_event_at(
1822            &touch_event(TouchId(1), TouchPhase::Moved, 100., 150.),
1823            now + Duration::from_millis(32),
1824        );
1825        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1826            panic!("expected scroll, got {recognized:?}");
1827        };
1828        assert_eq!(scroll.touch_phase, TouchPhase::Started);
1829    }
1830
1831    #[test]
1832    fn spline_position_table_matches_the_bezier_curve() {
1833        let samples = friction_spline::spline_position_samples();
1834        // AOSP's initializer solves sample 0 numerically like every other
1835        // sample, so it lands within solver tolerance of zero, not at zero.
1836        assert!(samples[0].abs() < 1e-4);
1837        assert_eq!(samples[100], 1.);
1838        for window in samples.windows(2) {
1839            assert!(window[0] < window[1], "table must be strictly increasing");
1840        }
1841        // Each table entry must lie on the defining parametric Bezier: for
1842        // sample i there must be a curve parameter whose time component is
1843        // i/100 and whose position component is the stored value.
1844        for (i, &stored_position) in samples.iter().enumerate().take(100) {
1845            let alpha = i as f32 / 100.;
1846            let (mut lower, mut upper) = (0f32, 1f32);
1847            for _ in 0..50 {
1848                let middle = (lower + upper) / 2.;
1849                let (time, _) = friction_spline::bezier_time_and_position(middle);
1850                if time > alpha {
1851                    upper = middle;
1852                } else {
1853                    lower = middle;
1854                }
1855            }
1856            let (time, position) = friction_spline::bezier_time_and_position((lower + upper) / 2.);
1857            assert!(
1858                (time - alpha).abs() < 1e-4,
1859                "sample {i}: time {time} != {alpha}"
1860            );
1861            assert!(
1862                (position - stored_position).abs() < 1e-3,
1863                "sample {i}: position {position} != stored {stored_position}"
1864            );
1865        }
1866    }
1867
1868    #[test]
1869    fn fling_curves_are_sane_for_both_physics() {
1870        for physics in [ScrollPhysics::ios(), ScrollPhysics::android()] {
1871            let slow = physics.fling_duration(500.);
1872            let fast = physics.fling_duration(4000.);
1873            assert!(slow > Duration::ZERO, "{physics:?}");
1874            assert!(fast > slow, "faster flings must coast longer: {physics:?}");
1875
1876            let halfway = physics.fling_distance(4000., fast / 2);
1877            let total = physics.fling_distance(4000., fast);
1878            assert!(halfway > 0. && halfway < total, "{physics:?}");
1879            assert!(
1880                physics.fling_distance(4000., fast * 2) == total,
1881                "distance must not grow past the fling duration: {physics:?}"
1882            );
1883            assert!(
1884                physics.fling_distance(4000., fast) > physics.fling_distance(500., slow),
1885                "faster flings must travel further: {physics:?}"
1886            );
1887        }
1888    }
1889
1890    #[test]
1891    fn momentum_is_frame_rate_independent() {
1892        // The same fling ticked at 60Hz and as one huge stalled frame must
1893        // cover identical ground.
1894        let total_distance_with_tick_length = |tick: Duration| -> f32 {
1895            let mut recognizer = TouchGestureRecognizer::new(GestureTuning {
1896                scroll_physics: ScrollPhysics::android(),
1897                ..GestureTuning::default()
1898            });
1899            let now = Instant::now();
1900            recognizer.handle_event_at(
1901                &touch_event(TouchId(1), TouchPhase::Started, 100., 500.),
1902                now,
1903            );
1904            for step in 1..=3 {
1905                recognizer.handle_event_at(
1906                    &touch_event(
1907                        TouchId(1),
1908                        TouchPhase::Moved,
1909                        100.,
1910                        500. - step as f32 * 40.,
1911                    ),
1912                    now + Duration::from_millis(step * 16),
1913                );
1914            }
1915            recognizer.handle_event_at(
1916                &touch_event(TouchId(1), TouchPhase::Ended, 100., 380.),
1917                now + Duration::from_millis(64),
1918            );
1919            assert!(recognizer.has_momentum());
1920
1921            let mut total = 0f32;
1922            let mut time = now + Duration::from_millis(64);
1923            let mut guard = 0;
1924            while recognizer.has_momentum() {
1925                time += tick;
1926                guard += 1;
1927                assert!(guard < 10_000, "momentum never stopped");
1928                if let Some(RecognizedTouchGesture::Scroll(scroll)) =
1929                    recognizer.tick_momentum_at(time)
1930                {
1931                    total += f32::from(scroll.delta.pixel_delta(px(16.)).y);
1932                }
1933            }
1934            total
1935        };
1936
1937        let smooth = total_distance_with_tick_length(Duration::from_millis(16));
1938        let stalled = total_distance_with_tick_length(Duration::from_secs(10));
1939        assert!(
1940            (smooth - stalled).abs() < 0.01,
1941            "expected identical fling distance, got {smooth} vs {stalled}"
1942        );
1943    }
1944
1945    #[test]
1946    fn flick_velocity_reflects_release_speed_not_window_average() {
1947        // A uniformly accelerating flick: position grows quadratically, so
1948        // the speed at the newest sample (2·k·t) is twice the window
1949        // average (k·t). The estimator must report the former.
1950        let mut velocity_tracker = VelocityTracker::default();
1951        let start = Instant::now();
1952        for step in 0..=6 {
1953            let t = step as f32 * 0.016;
1954            velocity_tracker.push(
1955                start + Duration::from_millis(step * 16),
1956                point(px(0.), px(1000. * t * t)),
1957            );
1958        }
1959        let velocity = velocity_tracker.velocity();
1960        let release_speed = 2. * 1000. * 0.096;
1961        assert!(
1962            (velocity.y - release_speed).abs() < 1.,
1963            "expected ≈{release_speed} px/s at release, got {} px/s",
1964            velocity.y
1965        );
1966        assert_eq!(velocity.x, 0.);
1967    }
1968
1969    #[test]
1970    fn samples_before_a_pause_do_not_contribute_velocity() {
1971        // Fast motion, then a hold longer than the stopped-finger gap, then
1972        // a slow nudge: only the motion after the pause describes the
1973        // release.
1974        let mut velocity_tracker = VelocityTracker::default();
1975        let start = Instant::now();
1976        velocity_tracker.push(start, point(px(0.), px(0.)));
1977        velocity_tracker.push(start + Duration::from_millis(16), point(px(0.), px(50.)));
1978        velocity_tracker.push(start + Duration::from_millis(80), point(px(0.), px(52.)));
1979        velocity_tracker.push(start + Duration::from_millis(96), point(px(0.), px(54.)));
1980        let velocity = velocity_tracker.velocity();
1981        assert!(
1982            velocity.y < 200.,
1983            "pre-pause motion leaked into the estimate: {} px/s",
1984            velocity.y
1985        );
1986    }
1987
1988    #[test]
1989    fn claimed_touch_drag_emits_phased_stream_without_pan_or_tap() {
1990        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
1991        let touch = TouchId(1);
1992        let now = Instant::now();
1993        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now);
1994        let Some(RecognizedTouchGesture::TouchDrag(started)) = recognizer.offer_touch_drag(touch)
1995        else {
1996            panic!("expected touch drag");
1997        };
1998        assert_eq!(started.phase, TouchPhase::Started);
1999        assert_eq!(started.start_position, point(px(10.), px(20.)));
2000        recognizer.resolve_touch_drag(true);
2001
2002        let moved = recognizer.handle_event_at(
2003            &touch_event(touch, TouchPhase::Moved, 40., 50.),
2004            now + Duration::from_millis(10),
2005        );
2006        let [RecognizedTouchGesture::TouchDrag(moved)] = moved.as_slice() else {
2007            panic!("expected moved touch drag, got {moved:?}");
2008        };
2009        assert_eq!(moved.phase, TouchPhase::Moved);
2010        assert_eq!(moved.position, point(px(40.), px(50.)));
2011
2012        let ended = recognizer.handle_event_at(
2013            &touch_event(touch, TouchPhase::Ended, 45., 55.),
2014            now + Duration::from_millis(20),
2015        );
2016        let [RecognizedTouchGesture::TouchDrag(ended)] = ended.as_slice() else {
2017            panic!("expected ended touch drag, got {ended:?}");
2018        };
2019        assert_eq!(ended.phase, TouchPhase::Ended);
2020        assert_eq!(ended.position, point(px(45.), px(55.)));
2021    }
2022
2023    #[test]
2024    fn unclaimed_touch_drag_remains_a_pan_candidate() {
2025        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2026        let touch = TouchId(1);
2027        let now = Instant::now();
2028        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now);
2029        assert!(recognizer.offer_touch_drag(touch).is_some());
2030        recognizer.resolve_touch_drag(false);
2031
2032        let moved = recognizer.handle_event_at(
2033            &touch_event(touch, TouchPhase::Moved, 20., 0.),
2034            now + Duration::from_millis(10),
2035        );
2036        assert!(matches!(
2037            moved.as_slice(),
2038            [RecognizedTouchGesture::Scroll(ScrollWheelEvent {
2039                touch_phase: TouchPhase::Started,
2040                ..
2041            })]
2042        ));
2043    }
2044
2045    #[test]
2046    fn claimed_long_press_emits_phased_stream_without_tap() {
2047        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2048        let touch = TouchId(1);
2049        let now = Instant::now();
2050        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now);
2051        let Some(RecognizedTouchGesture::LongPress(started)) = recognizer.offer_long_press(touch)
2052        else {
2053            panic!("expected long press");
2054        };
2055        assert_eq!(started.phase, TouchPhase::Started);
2056        assert_eq!(started.start_position, point(px(10.), px(20.)));
2057        recognizer.resolve_long_press(true);
2058
2059        let moved = recognizer.handle_event_at(
2060            &touch_event(touch, TouchPhase::Moved, 12., 21.),
2061            now + Duration::from_millis(510),
2062        );
2063        let [RecognizedTouchGesture::LongPress(moved)] = moved.as_slice() else {
2064            panic!("expected moved long press, got {moved:?}");
2065        };
2066        assert_eq!(moved.phase, TouchPhase::Moved);
2067
2068        let ended = recognizer.handle_event_at(
2069            &touch_event(touch, TouchPhase::Ended, 12., 21.),
2070            now + Duration::from_millis(520),
2071        );
2072        let [RecognizedTouchGesture::LongPress(ended)] = ended.as_slice() else {
2073            panic!("expected ended long press, got {ended:?}");
2074        };
2075        assert_eq!(ended.phase, TouchPhase::Ended);
2076    }
2077
2078    #[test]
2079    fn unclaimed_long_press_remains_a_tap_candidate() {
2080        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2081        let touch = TouchId(1);
2082        let now = Instant::now();
2083        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now);
2084        assert!(recognizer.offer_long_press(touch).is_some());
2085        recognizer.resolve_long_press(false);
2086
2087        let ended = recognizer.handle_event_at(
2088            &touch_event(touch, TouchPhase::Ended, 10., 20.),
2089            now + Duration::from_millis(510),
2090        );
2091        assert!(matches!(
2092            ended.as_slice(),
2093            [RecognizedTouchGesture::Tap { .. }]
2094        ));
2095    }
2096
2097    #[test]
2098    fn unclaimed_long_press_can_still_become_a_pan() {
2099        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2100        let touch = TouchId(1);
2101        let now = Instant::now();
2102        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now);
2103        assert!(recognizer.offer_long_press(touch).is_some());
2104        recognizer.resolve_long_press(false);
2105
2106        let moved = recognizer.handle_event_at(
2107            &touch_event(touch, TouchPhase::Moved, 20., 0.),
2108            now + Duration::from_millis(510),
2109        );
2110        assert!(matches!(
2111            moved.as_slice(),
2112            [RecognizedTouchGesture::Scroll(ScrollWheelEvent {
2113                touch_phase: TouchPhase::Started,
2114                ..
2115            })]
2116        ));
2117    }
2118
2119    #[test]
2120    fn long_press_offer_is_one_shot_and_specific_to_pending_touch() {
2121        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2122        let touch = TouchId(1);
2123        recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 0., 0.));
2124
2125        assert!(
2126            recognizer
2127                .handle_event(&touch_event(TouchId(2), TouchPhase::Moved, 20., 0.))
2128                .is_empty()
2129        );
2130        assert!(recognizer.offer_long_press(TouchId(2)).is_none());
2131        assert!(recognizer.offer_long_press(touch).is_some());
2132        assert!(recognizer.offer_long_press(touch).is_none());
2133    }
2134
2135    #[test]
2136    fn long_press_cannot_be_offered_after_pending_touch_resolves() {
2137        for phase in [TouchPhase::Ended, TouchPhase::Cancelled, TouchPhase::Moved] {
2138            let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2139            let touch = TouchId(1);
2140            let now = Instant::now();
2141            recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now);
2142            let position = if phase == TouchPhase::Moved { 20. } else { 0. };
2143            recognizer.handle_event_at(
2144                &touch_event(touch, phase, position, 0.),
2145                now + Duration::from_millis(10),
2146            );
2147            assert!(recognizer.offer_long_press(touch).is_none());
2148        }
2149    }
2150
2151    #[test]
2152    fn claimed_long_press_emits_cancelled_for_its_touch_only() {
2153        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2154        let touch = TouchId(1);
2155        recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 4., 5.));
2156        assert!(recognizer.offer_long_press(touch).is_some());
2157        recognizer.resolve_long_press(true);
2158
2159        assert!(
2160            recognizer
2161                .handle_event(&touch_event(TouchId(2), TouchPhase::Cancelled, 9., 9.))
2162                .is_empty()
2163        );
2164        let cancelled = recognizer.handle_event(&touch_event(touch, TouchPhase::Cancelled, 6., 7.));
2165        let [RecognizedTouchGesture::LongPress(cancelled)] = cancelled.as_slice() else {
2166            panic!("expected cancelled long press, got {cancelled:?}");
2167        };
2168        assert_eq!(cancelled.phase, TouchPhase::Cancelled);
2169        assert_eq!(cancelled.start_position, point(px(4.), px(5.)));
2170        assert_eq!(cancelled.position, point(px(6.), px(7.)));
2171    }
2172
2173    #[test]
2174    fn unrelated_touch_cannot_end_or_cancel_pending_touch() {
2175        for phase in [TouchPhase::Ended, TouchPhase::Cancelled] {
2176            let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2177            let touch = TouchId(1);
2178            recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 4., 5.));
2179
2180            assert!(
2181                recognizer
2182                    .handle_event(&touch_event(TouchId(2), phase, 9., 9.))
2183                    .is_empty()
2184            );
2185            assert!(recognizer.offer_long_press(touch).is_some());
2186        }
2187    }
2188
2189    #[test]
2190    fn completed_touch_id_cannot_claim_replacement_touch() {
2191        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
2192        let completed_touch = TouchId(1);
2193        let replacement_touch = TouchId(2);
2194        recognizer.handle_event(&touch_event(completed_touch, TouchPhase::Started, 0., 0.));
2195        recognizer.handle_event(&touch_event(completed_touch, TouchPhase::Cancelled, 0., 0.));
2196        recognizer.handle_event(&touch_event(replacement_touch, TouchPhase::Started, 5., 5.));
2197
2198        assert!(recognizer.offer_long_press(completed_touch).is_none());
2199        assert!(recognizer.offer_long_press(replacement_touch).is_some());
2200    }
2201
2202    fn touch_event(id: TouchId, phase: TouchPhase, x: f32, y: f32) -> TouchEvent {
2203        TouchEvent {
2204            id,
2205            phase,
2206            position: point(px(x), px(y)),
2207            predicted_position: None,
2208            force: None,
2209        }
2210    }
2211}