Skip to main content

gpui_authoring/
gestures.rs

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