Skip to main content

gpui/
gestures.rs

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