Skip to main content

gpui/
gestures.rs

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