Skip to main content

gpui/
gestures.rs

1//! Touch gesture recognition vocabulary.
2//!
3//! GPUI recognizes gestures from raw [`TouchEvent`](crate::TouchEvent)s in a
4//! single, portable arena in gpui core: recognizers compete for in-flight
5//! touches, winners claim them, and losers are cancelled. Recognized gestures
6//! are surfaced through *existing* semantic events wherever possible, a tap
7//! becomes [`ClickEvent::Touch`](crate::ClickEvent), a pan becomes
8//! [`ScrollWheelEvent`](crate::ScrollWheelEvent)s carrying a
9//! [`TouchPhase`](crate::TouchPhase), and a pinch becomes
10//! [`PinchEvent`](crate::PinchEvent)s — so components written against
11//! `on_click` and scroll containers work untouched on mobile.
12
13use std::collections::VecDeque;
14use std::mem;
15use std::time::Duration;
16
17use scheduler::Instant;
18use smallvec::SmallVec;
19
20use crate::{
21    Axis, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Pixels, Point, ScrollDelta,
22    ScrollWheelEvent, TouchEvent, TouchId, TouchPhase, point, px,
23};
24
25const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
26
27/// Tracks the dominant axis across the events in a scroll gesture.
28#[derive(Clone, Copy, Debug, Default)]
29pub struct OngoingScroll {
30    last_event: Option<Instant>,
31    axis: Option<Axis>,
32}
33
34impl OngoingScroll {
35    /// Filters the given delta to the dominant axis of the current scroll gesture.
36    ///
37    /// Gestures are delimited by their touch phase when available, with a timeout
38    /// fallback for platforms that only emit [`TouchPhase::Moved`].
39    pub fn filter(&mut self, delta: &mut Point<Pixels>, touch_phase: TouchPhase) {
40        self.filter_at(delta, touch_phase, Instant::now())
41    }
42
43    fn filter_at(&mut self, delta: &mut Point<Pixels>, touch_phase: TouchPhase, now: Instant) {
44        const UNLOCK_PERCENT: f32 = 1.9;
45        const UNLOCK_LOWER_BOUND: Pixels = px(6.);
46
47        if matches!(touch_phase, TouchPhase::Ended | TouchPhase::Cancelled) {
48            self.last_event = None;
49            self.axis = None;
50            return;
51        }
52
53        let x = delta.x.abs();
54        let y = delta.y.abs();
55        if x.is_zero() && y.is_zero() {
56            if touch_phase == TouchPhase::Started {
57                self.last_event = None;
58                self.axis = None;
59            }
60            return;
61        }
62
63        let starts_new_gesture = touch_phase == TouchPhase::Started
64            || self
65                .last_event
66                .is_none_or(|last_event| now.duration_since(last_event) >= SCROLL_EVENT_SEPARATION);
67        let mut axis = self.axis;
68        if starts_new_gesture {
69            axis = if x <= y {
70                Some(Axis::Vertical)
71            } else {
72                Some(Axis::Horizontal)
73            };
74        } else if x.max(y) >= UNLOCK_LOWER_BOUND {
75            match axis {
76                Some(Axis::Vertical) if x > y && x >= y * UNLOCK_PERCENT => {
77                    axis = None;
78                }
79                Some(Axis::Horizontal) if y > x && y >= x * UNLOCK_PERCENT => {
80                    axis = None;
81                }
82                _ => {}
83            }
84        }
85
86        self.last_event = Some(now);
87        self.axis = axis;
88        match axis {
89            Some(Axis::Vertical) => delta.x = Pixels::ZERO,
90            Some(Axis::Horizontal) => delta.y = Pixels::ZERO,
91            None => {}
92        }
93    }
94}
95
96/// Feel constants consumed by gesture recognizers. Provided on a best-effort
97/// basis, depending on each platform's support, defaulting to GPUI's own
98/// (iOS flavored) values
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub struct GestureTuning {
101    /// Distance a touch may travel before it stops being a potential tap and
102    /// becomes a pan/drag.
103    pub touch_slop: Pixels,
104    /// Maximum interval between taps for them to accumulate a tap count.
105    pub multi_tap_interval: Duration,
106    /// Maximum distance between taps for them to accumulate a tap count.
107    pub multi_tap_slop: Pixels,
108    /// How long a touch must remain within [`Self::touch_slop`] to be
109    /// recognized as a long press.
110    pub long_press_duration: Duration,
111    /// Per-millisecond decay factor applied to scroll momentum after a fling.
112    /// (`UIScrollView` uses `0.998` per millisecond for its normal
113    /// deceleration rate.)
114    pub momentum_decay_per_ms: f32,
115    /// Minimum release velocity, in pixels per second, required to start
116    /// scroll momentum.
117    pub min_fling_velocity: f32,
118}
119
120impl Default for GestureTuning {
121    fn default() -> Self {
122        Self {
123            touch_slop: px(8.),
124            multi_tap_interval: Duration::from_millis(400),
125            multi_tap_slop: px(16.),
126            long_press_duration: Duration::from_millis(500),
127            momentum_decay_per_ms: 0.998,
128            min_fling_velocity: 50.,
129        }
130    }
131}
132
133/// The set of gesture kinds that participate in recognition.
134///
135/// Used by [`PlatformGestures::native_recognizers`] to declare which gestures
136/// the platform recognizes natively rather than leaving to gpui core's
137/// portable recognizers.
138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub struct GestureKinds {
140    /// Tap (and multi-tap), surfaced as [`ClickEvent::Touch`](crate::ClickEvent).
141    pub tap: bool,
142    /// Long press, surfaced as [`LongPressEvent`].
143    pub long_press: bool,
144    /// Pan/scroll (including fling momentum), surfaced as
145    /// [`ScrollWheelEvent`](crate::ScrollWheelEvent)s.
146    pub pan: bool,
147    /// Pinch to zoom, surfaced as [`PinchEvent`](crate::PinchEvent)s.
148    pub pinch: bool,
149}
150
151impl GestureKinds {
152    /// No gestures; gpui core's portable recognizers handle everything.
153    pub const NONE: Self = Self {
154        tap: false,
155        long_press: false,
156        pan: false,
157        pinch: false,
158    };
159
160    /// All gesture kinds.
161    pub const ALL: Self = Self {
162        tap: true,
163        long_press: true,
164        pan: true,
165        pinch: true,
166    };
167}
168
169/// A long-press gesture, mobile's context-menu trigger.
170///
171/// A bare long press is surfaced as a [`ClickEvent`](crate::ClickEvent) with
172/// `long_press: true`, delivered to aux-click listeners alongside right
173/// clicks. This event is the raw hook for elements that need the gesture
174/// itself (e.g. long-press to start a drag); the registration API ships
175/// together with the gesture arena.
176#[derive(Clone, Debug, Default)]
177pub struct LongPressEvent {
178    /// The position of the touch that was recognized as a long press.
179    pub position: Point<Pixels>,
180}
181
182/// Platform gesture recognition services.
183///
184/// If your mobile platform supports native gesture recognition, use this
185/// to share it with GPUI.
186pub trait PlatformGestures {
187    /// Feel constants for the portable recognizers on this platform.
188    fn tuning(&self) -> GestureTuning {
189        GestureTuning::default()
190    }
191
192    /// The gesture kinds this platform recognizes natively.
193    fn native_recognizers(&self) -> GestureKinds {
194        GestureKinds::NONE
195    }
196}
197
198/// A no-op [`PlatformGestures`] implementation: no native recognizers and
199/// default tuning. Suitable for desktop platforms and tests.
200pub struct NullPlatformGestures;
201
202impl PlatformGestures for NullPlatformGestures {}
203
204/// Ceiling on recognized fling velocity, in pixels per second (matches
205/// Flutter's `kMaxFlingVelocity`).
206const MAX_FLING_VELOCITY: f32 = 8000.;
207
208/// Momentum below this speed, in pixels per second, is imperceptible: the
209/// fling stops and the synthetic scroll stream is closed.
210const MOMENTUM_STOP_VELOCITY: f32 = 10.;
211
212/// Upper bound on the time a single momentum tick may integrate, so a stalled
213/// frame loop (backgrounded window, long pause) resumes without a huge jump.
214/// Must stay well above a plausible worst-case frame interval: clamping a
215/// normal slow frame would advance the fling slower than real time, making
216/// momentum crawl on exactly the devices that already render slowly.
217const MOMENTUM_MAX_TICK: Duration = Duration::from_millis(250);
218
219/// How far back the release-velocity estimate looks. Samples older than this
220/// reflect an earlier part of the gesture, not the speed at release.
221const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
222
223/// A pause between samples longer than this means the finger stopped:
224/// anything before the pause describes an earlier motion, not the release
225/// (Flutter's `kAssumePointerMoveStoppedMilliseconds`). Touch hardware
226/// reports movement every 8–16ms while the finger is in motion.
227const VELOCITY_ASSUME_STOPPED_GAP: Duration = Duration::from_millis(40);
228
229const VELOCITY_MAX_SAMPLES: usize = 20;
230
231/// The portable recognizer behind raw touch input: it watches the
232/// [`TouchEvent`] stream for one touch at a time and resolves it into either
233/// a tap or a pan, following the competition model described in the module
234/// docs. Pans continue into post-release momentum when the touch lifts at
235/// speed; the window drives that phase through [`Self::tick_momentum`].
236///
237/// Taps are currently surfaced as synthesized mouse presses rather than
238/// [`ClickEvent::Touch`](crate::ClickEvent), which keeps every existing
239/// mouse-driven behavior (click listeners, caret placement, double-tap
240/// selection) working before elements grow a direct tap-delivery path.
241/// Long-press and pinch recognition are not implemented yet, and additional
242/// touches are ignored while one is being recognized.
243pub(crate) struct TouchGestureRecognizer {
244    tuning: GestureTuning,
245    state: TouchGestureState,
246    momentum: Option<Momentum>,
247    last_tap: Option<CompletedTap>,
248}
249
250/// A semantic event recognized from raw touches, ready to dispatch through
251/// the window's existing input paths.
252#[derive(Debug)]
253pub(crate) enum RecognizedTouchGesture {
254    /// One step of a pan (or of its post-release momentum), delivered to
255    /// scroll listeners at the pan's starting position.
256    Scroll(ScrollWheelEvent),
257    /// A recognized tap, delivered as a synthesized mouse press and release.
258    Tap {
259        down: MouseDownEvent,
260        up: MouseUpEvent,
261    },
262}
263
264enum TouchGestureState {
265    Idle,
266    /// The touch is still within `touch_slop` of where it started: it can
267    /// still resolve into either a tap or a pan.
268    Pending(ActiveTouch),
269    /// The touch exceeded `touch_slop`: it is a pan until it ends, and its
270    /// movement flows out as scroll events.
271    Panning(ActiveTouch),
272}
273
274struct ActiveTouch {
275    id: TouchId,
276    start_position: Point<Pixels>,
277    last_position: Point<Pixels>,
278    velocity_tracker: VelocityTracker,
279}
280
281struct CompletedTap {
282    position: Point<Pixels>,
283    time: Instant,
284    count: usize,
285}
286
287struct Momentum {
288    /// Where the pan started; synthesized scroll events keep hit-testing
289    /// there so momentum stays with the container the gesture began on.
290    position: Point<Pixels>,
291    /// Pixels per second.
292    velocity: Point<f32>,
293    last_tick: Instant,
294}
295
296impl TouchGestureRecognizer {
297    pub(crate) fn new(tuning: GestureTuning) -> Self {
298        Self {
299            tuning,
300            state: TouchGestureState::Idle,
301            momentum: None,
302            last_tap: None,
303        }
304    }
305
306    pub(crate) fn handle_event(
307        &mut self,
308        event: &TouchEvent,
309    ) -> SmallVec<[RecognizedTouchGesture; 2]> {
310        self.handle_event_at(event, Instant::now())
311    }
312
313    fn handle_event_at(
314        &mut self,
315        event: &TouchEvent,
316        now: Instant,
317    ) -> SmallVec<[RecognizedTouchGesture; 2]> {
318        let mut recognized = SmallVec::new();
319        match event.phase {
320            TouchPhase::Started => {
321                if let Some(momentum) = self.momentum.take() {
322                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
323                        momentum.position,
324                        Point::default(),
325                        TouchPhase::Ended,
326                    )));
327                }
328                if matches!(self.state, TouchGestureState::Idle) {
329                    let mut velocity_tracker = VelocityTracker::default();
330                    velocity_tracker.push(now, event.position);
331                    self.state = TouchGestureState::Pending(ActiveTouch {
332                        id: event.id,
333                        start_position: event.position,
334                        last_position: event.position,
335                        velocity_tracker,
336                    });
337                }
338            }
339            TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) {
340                TouchGestureState::Pending(mut touch) if touch.id == event.id => {
341                    touch.velocity_tracker.push(now, event.position);
342                    let accumulated = event.position - touch.start_position;
343                    if accumulated.magnitude() > f64::from(self.tuning.touch_slop) {
344                        // Carry the full movement so far into the first scroll
345                        // step: the content catches up to the finger instead
346                        // of losing the slop distance.
347                        touch.last_position = event.position;
348                        recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
349                            touch.start_position,
350                            accumulated,
351                            TouchPhase::Started,
352                        )));
353                        self.state = TouchGestureState::Panning(touch);
354                    } else {
355                        self.state = TouchGestureState::Pending(touch);
356                    }
357                }
358                TouchGestureState::Panning(mut touch) if touch.id == event.id => {
359                    touch.velocity_tracker.push(now, event.position);
360                    let delta = event.position - touch.last_position;
361                    touch.last_position = event.position;
362                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
363                        touch.start_position,
364                        delta,
365                        TouchPhase::Moved,
366                    )));
367                    self.state = TouchGestureState::Panning(touch);
368                }
369                other => self.state = other,
370            },
371            TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) {
372                TouchGestureState::Pending(touch) if touch.id == event.id => {
373                    let tap_count = match &self.last_tap {
374                        Some(tap)
375                            if now.duration_since(tap.time) <= self.tuning.multi_tap_interval
376                                && (event.position - tap.position).magnitude()
377                                    <= f64::from(self.tuning.multi_tap_slop) =>
378                        {
379                            tap.count + 1
380                        }
381                        _ => 1,
382                    };
383                    self.last_tap = Some(CompletedTap {
384                        position: event.position,
385                        time: now,
386                        count: tap_count,
387                    });
388                    recognized.push(RecognizedTouchGesture::Tap {
389                        down: MouseDownEvent {
390                            button: MouseButton::Left,
391                            position: event.position,
392                            modifiers: Modifiers::default(),
393                            click_count: tap_count,
394                            first_mouse: false,
395                        },
396                        up: MouseUpEvent {
397                            button: MouseButton::Left,
398                            position: event.position,
399                            modifiers: Modifiers::default(),
400                            click_count: tap_count,
401                        },
402                    });
403                }
404                TouchGestureState::Panning(touch) if touch.id == event.id => {
405                    let delta = event.position - touch.last_position;
406                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
407                        touch.start_position,
408                        delta,
409                        TouchPhase::Ended,
410                    )));
411                    // The release deliberately contributes no velocity
412                    // sample: it usually repeats the last movement's position
413                    // with a later timestamp, which would dilute the
414                    // estimate. But a release long after the last movement
415                    // means the finger had already stopped, so nothing
416                    // flings.
417                    let finger_stopped =
418                        touch
419                            .velocity_tracker
420                            .latest_sample_time()
421                            .is_none_or(|latest| {
422                                now.duration_since(latest) > VELOCITY_ASSUME_STOPPED_GAP
423                            });
424                    let velocity = if finger_stopped {
425                        Point::default()
426                    } else {
427                        touch.velocity_tracker.velocity()
428                    };
429                    let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt();
430                    if speed >= self.tuning.min_fling_velocity {
431                        let velocity = if speed > MAX_FLING_VELOCITY {
432                            velocity * (MAX_FLING_VELOCITY / speed)
433                        } else {
434                            velocity
435                        };
436                        self.momentum = Some(Momentum {
437                            position: touch.start_position,
438                            velocity,
439                            last_tick: now,
440                        });
441                    }
442                }
443                other => self.state = other,
444            },
445            TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) {
446                TouchGestureState::Pending(touch) if touch.id == event.id => {}
447                TouchGestureState::Panning(touch) if touch.id == event.id => {
448                    recognized.push(RecognizedTouchGesture::Scroll(scroll_event(
449                        touch.start_position,
450                        Point::default(),
451                        TouchPhase::Cancelled,
452                    )));
453                }
454                other => self.state = other,
455            },
456        }
457        recognized
458    }
459
460    pub(crate) fn has_momentum(&self) -> bool {
461        self.momentum.is_some()
462    }
463
464    /// Advances post-fling momentum by one frame, returning the scroll step
465    /// to dispatch, or `None` when no momentum is in progress. The final step
466    /// carries [`TouchPhase::Ended`] to close the synthetic scroll stream.
467    pub(crate) fn tick_momentum(&mut self) -> Option<RecognizedTouchGesture> {
468        self.tick_momentum_at(Instant::now())
469    }
470
471    fn tick_momentum_at(&mut self, now: Instant) -> Option<RecognizedTouchGesture> {
472        let momentum = self.momentum.as_mut()?;
473        let elapsed = now
474            .duration_since(momentum.last_tick)
475            .min(MOMENTUM_MAX_TICK);
476        momentum.last_tick = now;
477        let delta = point(
478            px(momentum.velocity.x * elapsed.as_secs_f32()),
479            px(momentum.velocity.y * elapsed.as_secs_f32()),
480        );
481        momentum.velocity *= self
482            .tuning
483            .momentum_decay_per_ms
484            .powf(elapsed.as_secs_f32() * 1000.);
485        let speed = (momentum.velocity.x.powi(2) + momentum.velocity.y.powi(2)).sqrt();
486        let position = momentum.position;
487        if speed < MOMENTUM_STOP_VELOCITY {
488            self.momentum = None;
489            Some(RecognizedTouchGesture::Scroll(scroll_event(
490                position,
491                delta,
492                TouchPhase::Ended,
493            )))
494        } else {
495            Some(RecognizedTouchGesture::Scroll(scroll_event(
496                position,
497                delta,
498                TouchPhase::Moved,
499            )))
500        }
501    }
502}
503
504fn scroll_event(
505    position: Point<Pixels>,
506    delta: Point<Pixels>,
507    touch_phase: TouchPhase,
508) -> ScrollWheelEvent {
509    ScrollWheelEvent {
510        position,
511        delta: ScrollDelta::Pixels(delta),
512        modifiers: Modifiers::default(),
513        touch_phase,
514    }
515}
516
517/// Estimates the velocity a touch had at its newest sample.
518#[derive(Default)]
519struct VelocityTracker {
520    samples: VecDeque<(Instant, Point<Pixels>)>,
521}
522
523impl VelocityTracker {
524    fn push(&mut self, time: Instant, position: Point<Pixels>) {
525        self.samples.push_back((time, position));
526        while self.samples.len() > VELOCITY_MAX_SAMPLES {
527            self.samples.pop_front();
528        }
529    }
530
531    fn latest_sample_time(&self) -> Option<Instant> {
532        self.samples.back().map(|(time, _)| *time)
533    }
534
535    /// The velocity at the newest sample, in pixels per second.
536    ///
537    /// Fits a second-degree polynomial by least squares over the trailing
538    /// [`VELOCITY_WINDOW`] and takes its derivative at the newest sample,
539    /// like Flutter's `VelocityTracker` and Android's `lsq2` strategy. An
540    /// endpoint difference over the same window would report the window's
541    /// *average* speed, which for a flick — still accelerating at lift-off —
542    /// is roughly half the speed the finger actually had at release.
543    fn velocity(&self) -> Point<f32> {
544        let Some((newest_time, _)) = self.samples.back() else {
545            return Point::default();
546        };
547        let mut times_seconds: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new();
548        let mut horizontal: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new();
549        let mut vertical: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new();
550        let mut previous_time = *newest_time;
551        for (time, position) in self.samples.iter().rev() {
552            let age = newest_time.duration_since(*time);
553            if age > VELOCITY_WINDOW
554                || previous_time.duration_since(*time) > VELOCITY_ASSUME_STOPPED_GAP
555            {
556                break;
557            }
558            previous_time = *time;
559            times_seconds.push(-age.as_secs_f64());
560            horizontal.push(f64::from(f32::from(position.x)));
561            vertical.push(f64::from(f32::from(position.y)));
562        }
563
564        let endpoint_estimate = |values: &[f64]| -> f32 {
565            let elapsed = -times_seconds.last().copied().unwrap_or(0.);
566            if elapsed <= f64::EPSILON {
567                return 0.;
568            }
569            ((values.first().copied().unwrap_or(0.) - values.last().copied().unwrap_or(0.))
570                / elapsed) as f32
571        };
572        if times_seconds.len() < 3 {
573            return point(endpoint_estimate(&horizontal), endpoint_estimate(&vertical));
574        }
575        point(
576            quadratic_velocity_at_newest(&times_seconds, &horizontal).map_or_else(
577                || endpoint_estimate(&horizontal),
578                |velocity| velocity as f32,
579            ),
580            quadratic_velocity_at_newest(&times_seconds, &vertical)
581                .map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32),
582        )
583    }
584}
585
586/// Least-squares fit of `value = a0 + a1·t + a2·t²` returning `a1`: the
587/// fitted curve's velocity at `t = 0`, which callers place at the newest
588/// sample. `None` when the samples are too degenerate to fit (all
589/// simultaneous, for example).
590fn quadratic_velocity_at_newest(times: &[f64], values: &[f64]) -> Option<f64> {
591    let count = times.len() as f64;
592    let (mut sum_t1, mut sum_t2, mut sum_t3, mut sum_t4) = (0., 0., 0., 0.);
593    let (mut sum_v, mut sum_vt, mut sum_vt2) = (0., 0., 0.);
594    for (&time, &value) in times.iter().zip(values) {
595        let time_squared = time * time;
596        sum_t1 += time;
597        sum_t2 += time_squared;
598        sum_t3 += time_squared * time;
599        sum_t4 += time_squared * time_squared;
600        sum_v += value;
601        sum_vt += value * time;
602        sum_vt2 += value * time_squared;
603    }
604    // Cramer's rule on the 3×3 normal equations, solved for the linear
605    // coefficient only.
606    let determinant = count * (sum_t2 * sum_t4 - sum_t3 * sum_t3)
607        - sum_t1 * (sum_t1 * sum_t4 - sum_t3 * sum_t2)
608        + sum_t2 * (sum_t1 * sum_t3 - sum_t2 * sum_t2);
609    if determinant.abs() < 1e-12 {
610        return None;
611    }
612    let linear_determinant = count * (sum_vt * sum_t4 - sum_t3 * sum_vt2)
613        - sum_v * (sum_t1 * sum_t4 - sum_t3 * sum_t2)
614        + sum_t2 * (sum_t1 * sum_vt2 - sum_vt * sum_t2);
615    Some(linear_determinant / determinant)
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::point;
622
623    #[test]
624    fn ongoing_scroll_locks_to_dominant_axis() {
625        let now = Instant::now();
626        let mut ongoing_scroll = OngoingScroll::default();
627        let mut horizontal_delta = point(px(10.), px(2.));
628        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
629        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
630        assert_eq!(horizontal_delta, point(px(10.), px(0.)));
631
632        let mut continued_delta = point(px(3.), px(2.));
633        ongoing_scroll.filter_at(
634            &mut continued_delta,
635            TouchPhase::Moved,
636            now + Duration::from_millis(1),
637        );
638        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
639        assert_eq!(continued_delta, point(px(3.), px(0.)));
640    }
641
642    #[test]
643    fn ongoing_scroll_unlocks_when_direction_changes() {
644        let now = Instant::now();
645        let mut ongoing_scroll = OngoingScroll::default();
646        let mut horizontal_delta = point(px(10.), px(2.));
647        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
648
649        let mut vertical_delta = point(px(2.), px(10.));
650        ongoing_scroll.filter_at(
651            &mut vertical_delta,
652            TouchPhase::Moved,
653            now + Duration::from_millis(1),
654        );
655        assert_eq!(ongoing_scroll.axis, None);
656        assert_eq!(vertical_delta, point(px(2.), px(10.)));
657    }
658
659    #[test]
660    fn ongoing_scroll_starts_new_gesture_at_timeout_boundary() {
661        let now = Instant::now();
662        let mut ongoing_scroll = OngoingScroll::default();
663        let mut horizontal_delta = point(px(10.), px(2.));
664        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now);
665
666        let mut vertical_delta = point(px(2.), px(10.));
667        ongoing_scroll.filter_at(
668            &mut vertical_delta,
669            TouchPhase::Moved,
670            now + SCROLL_EVENT_SEPARATION,
671        );
672        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
673        assert_eq!(vertical_delta, point(px(0.), px(10.)));
674    }
675
676    #[test]
677    fn ongoing_scroll_ignores_zero_delta_and_resets_when_ended() {
678        let now = Instant::now();
679        let mut ongoing_scroll = OngoingScroll::default();
680        let mut horizontal_delta = point(px(10.), px(2.));
681        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
682
683        let mut zero_delta = Point::default();
684        ongoing_scroll.filter_at(
685            &mut zero_delta,
686            TouchPhase::Ended,
687            now + Duration::from_millis(1),
688        );
689        assert_eq!(ongoing_scroll.axis, None);
690
691        let mut vertical_delta = point(px(2.), px(3.));
692        ongoing_scroll.filter_at(
693            &mut vertical_delta,
694            TouchPhase::Moved,
695            now + Duration::from_millis(2),
696        );
697        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
698        assert_eq!(vertical_delta, point(px(0.), px(3.)));
699    }
700
701    #[test]
702    fn ongoing_scroll_ignores_zero_delta_movement() {
703        let now = Instant::now();
704        let mut ongoing_scroll = OngoingScroll::default();
705        let mut horizontal_delta = point(px(10.), px(2.));
706        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
707
708        let mut zero_delta = Point::default();
709        ongoing_scroll.filter_at(
710            &mut zero_delta,
711            TouchPhase::Moved,
712            now + SCROLL_EVENT_SEPARATION,
713        );
714
715        let mut vertical_delta = point(px(2.), px(10.));
716        ongoing_scroll.filter_at(
717            &mut vertical_delta,
718            TouchPhase::Moved,
719            now + SCROLL_EVENT_SEPARATION,
720        );
721        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
722        assert_eq!(vertical_delta, point(px(0.), px(10.)));
723    }
724
725    #[test]
726    fn ongoing_scroll_supports_moved_only_platforms() {
727        let now = Instant::now();
728        let mut ongoing_scroll = OngoingScroll::default();
729        let mut horizontal_delta = point(px(10.), px(2.));
730        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now);
731        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
732        assert_eq!(horizontal_delta, point(px(10.), px(0.)));
733    }
734
735    #[test]
736    fn touch_within_slop_resolves_to_tap() {
737        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
738        let now = Instant::now();
739        let touch = TouchId(1);
740
741        let recognized =
742            recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 10.), now);
743        assert!(recognized.is_empty());
744        let recognized = recognizer.handle_event_at(
745            &touch_event(touch, TouchPhase::Moved, 12., 11.),
746            now + Duration::from_millis(20),
747        );
748        assert!(recognized.is_empty());
749
750        let recognized = recognizer.handle_event_at(
751            &touch_event(touch, TouchPhase::Ended, 12., 11.),
752            now + Duration::from_millis(60),
753        );
754        let [RecognizedTouchGesture::Tap { down, up }] = recognized.as_slice() else {
755            panic!("expected tap, got {recognized:?}");
756        };
757        assert_eq!(down.click_count, 1);
758        assert_eq!(down.position, point(px(12.), px(11.)));
759        assert_eq!(up.click_count, 1);
760    }
761
762    #[test]
763    fn consecutive_taps_accumulate_tap_count() {
764        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
765        let now = Instant::now();
766
767        recognizer.handle_event_at(&touch_event(TouchId(1), TouchPhase::Started, 10., 10.), now);
768        recognizer.handle_event_at(
769            &touch_event(TouchId(1), TouchPhase::Ended, 10., 10.),
770            now + Duration::from_millis(40),
771        );
772
773        let second_down = now + Duration::from_millis(200);
774        recognizer.handle_event_at(
775            &touch_event(TouchId(2), TouchPhase::Started, 14., 10.),
776            second_down,
777        );
778        let recognized = recognizer.handle_event_at(
779            &touch_event(TouchId(2), TouchPhase::Ended, 14., 10.),
780            second_down + Duration::from_millis(40),
781        );
782        let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else {
783            panic!("expected tap, got {recognized:?}");
784        };
785        assert_eq!(down.click_count, 2);
786
787        let late_down = second_down + Duration::from_secs(2);
788        recognizer.handle_event_at(
789            &touch_event(TouchId(3), TouchPhase::Started, 14., 10.),
790            late_down,
791        );
792        let recognized = recognizer.handle_event_at(
793            &touch_event(TouchId(3), TouchPhase::Ended, 14., 10.),
794            late_down + Duration::from_millis(40),
795        );
796        let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else {
797            panic!("expected tap, got {recognized:?}");
798        };
799        assert_eq!(down.click_count, 1);
800    }
801
802    #[test]
803    fn touch_beyond_slop_resolves_to_pan() {
804        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
805        let now = Instant::now();
806        let touch = TouchId(1);
807
808        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
809
810        let recognized = recognizer.handle_event_at(
811            &touch_event(touch, TouchPhase::Moved, 100., 120.),
812            now + Duration::from_millis(16),
813        );
814        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
815            panic!("expected scroll, got {recognized:?}");
816        };
817        assert_eq!(scroll.touch_phase, TouchPhase::Started);
818        assert_eq!(scroll.position, point(px(100.), px(100.)));
819        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.)));
820
821        let recognized = recognizer.handle_event_at(
822            &touch_event(touch, TouchPhase::Moved, 100., 135.),
823            now + Duration::from_millis(32),
824        );
825        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
826            panic!("expected scroll, got {recognized:?}");
827        };
828        assert_eq!(scroll.touch_phase, TouchPhase::Moved);
829        assert_eq!(scroll.position, point(px(100.), px(100.)));
830        assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(15.)));
831
832        let recognized = recognizer.handle_event_at(
833            &touch_event(touch, TouchPhase::Ended, 100., 135.),
834            now + Duration::from_millis(48),
835        );
836        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
837            panic!("expected scroll, got {recognized:?}");
838        };
839        assert_eq!(scroll.touch_phase, TouchPhase::Ended);
840    }
841
842    #[test]
843    fn fast_release_starts_momentum_that_decays_to_a_stop() {
844        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
845        let now = Instant::now();
846        let touch = TouchId(1);
847
848        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now);
849        for step in 1..=5 {
850            recognizer.handle_event_at(
851                &touch_event(touch, TouchPhase::Moved, 100., 300. - step as f32 * 20.),
852                now + Duration::from_millis(step * 16),
853            );
854        }
855        recognizer.handle_event_at(
856            &touch_event(touch, TouchPhase::Ended, 100., 200.),
857            now + Duration::from_millis(6 * 16),
858        );
859        assert!(recognizer.has_momentum());
860
861        let tick = now + Duration::from_millis(6 * 16 + 16);
862        let recognized = recognizer.tick_momentum_at(tick);
863        let Some(RecognizedTouchGesture::Scroll(scroll)) = recognized else {
864            panic!("expected momentum scroll, got {recognized:?}");
865        };
866        assert_eq!(scroll.touch_phase, TouchPhase::Moved);
867        assert_eq!(scroll.position, point(px(100.), px(300.)));
868        let delta = scroll.delta.pixel_delta(px(16.));
869        assert!(
870            delta.y < px(0.),
871            "momentum should continue upward, got {delta:?}"
872        );
873        // The least-squares fit may leave float residue on the motionless axis.
874        assert!(
875            delta.x.abs() < px(0.001),
876            "expected no x motion, got {delta:?}"
877        );
878
879        let mut last_phase = TouchPhase::Moved;
880        let mut ticks = 0;
881        let mut time = tick;
882        while recognizer.has_momentum() {
883            time += Duration::from_millis(16);
884            ticks += 1;
885            assert!(ticks < 1000, "momentum never stopped");
886            if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time)
887            {
888                last_phase = scroll.touch_phase;
889            }
890        }
891        assert_eq!(last_phase, TouchPhase::Ended);
892        assert!(recognizer.tick_momentum_at(time).is_none());
893    }
894
895    #[test]
896    fn slow_release_does_not_start_momentum() {
897        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
898        let now = Instant::now();
899        let touch = TouchId(1);
900
901        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now);
902        recognizer.handle_event_at(
903            &touch_event(touch, TouchPhase::Moved, 100., 280.),
904            now + Duration::from_millis(16),
905        );
906        recognizer.handle_event_at(
907            &touch_event(touch, TouchPhase::Moved, 100., 279.),
908            now + Duration::from_millis(500),
909        );
910        recognizer.handle_event_at(
911            &touch_event(touch, TouchPhase::Ended, 100., 279.),
912            now + Duration::from_millis(600),
913        );
914        assert!(!recognizer.has_momentum());
915    }
916
917    #[test]
918    fn new_touch_interrupts_momentum() {
919        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
920        let now = Instant::now();
921
922        recognizer.handle_event_at(
923            &touch_event(TouchId(1), TouchPhase::Started, 100., 300.),
924            now,
925        );
926        for step in 1..=3 {
927            recognizer.handle_event_at(
928                &touch_event(
929                    TouchId(1),
930                    TouchPhase::Moved,
931                    100.,
932                    300. - step as f32 * 33.,
933                ),
934                now + Duration::from_millis(step * 16),
935            );
936        }
937        recognizer.handle_event_at(
938            &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.),
939            now + Duration::from_millis(64),
940        );
941        assert!(recognizer.has_momentum());
942
943        let recognized = recognizer.handle_event_at(
944            &touch_event(TouchId(2), TouchPhase::Started, 100., 200.),
945            now + Duration::from_millis(200),
946        );
947        assert!(!recognizer.has_momentum());
948        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
949            panic!("expected closing scroll, got {recognized:?}");
950        };
951        assert_eq!(scroll.touch_phase, TouchPhase::Ended);
952        assert!(scroll.delta.pixel_delta(px(16.)).is_zero());
953    }
954
955    #[test]
956    fn cancelled_pan_emits_cancelled_scroll_and_no_tap() {
957        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
958        let now = Instant::now();
959        let touch = TouchId(1);
960
961        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
962        recognizer.handle_event_at(
963            &touch_event(touch, TouchPhase::Moved, 100., 150.),
964            now + Duration::from_millis(16),
965        );
966        let recognized = recognizer.handle_event_at(
967            &touch_event(touch, TouchPhase::Cancelled, 100., 150.),
968            now + Duration::from_millis(32),
969        );
970        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
971            panic!("expected cancelled scroll, got {recognized:?}");
972        };
973        assert_eq!(scroll.touch_phase, TouchPhase::Cancelled);
974        assert!(!recognizer.has_momentum());
975
976        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
977        recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now);
978        let recognized = recognizer.handle_event_at(
979            &touch_event(touch, TouchPhase::Cancelled, 100., 102.),
980            now + Duration::from_millis(16),
981        );
982        assert!(recognized.is_empty(), "cancelled tap must not click");
983    }
984
985    #[test]
986    fn concurrent_touches_are_ignored_while_one_is_active() {
987        let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
988        let now = Instant::now();
989
990        recognizer.handle_event_at(
991            &touch_event(TouchId(1), TouchPhase::Started, 100., 100.),
992            now,
993        );
994        let recognized = recognizer.handle_event_at(
995            &touch_event(TouchId(2), TouchPhase::Started, 200., 200.),
996            now + Duration::from_millis(8),
997        );
998        assert!(recognized.is_empty());
999        let recognized = recognizer.handle_event_at(
1000            &touch_event(TouchId(2), TouchPhase::Moved, 200., 300.),
1001            now + Duration::from_millis(16),
1002        );
1003        assert!(recognized.is_empty());
1004        let recognized = recognizer.handle_event_at(
1005            &touch_event(TouchId(2), TouchPhase::Ended, 200., 300.),
1006            now + Duration::from_millis(24),
1007        );
1008        assert!(recognized.is_empty());
1009
1010        // The first touch still resolves normally.
1011        let recognized = recognizer.handle_event_at(
1012            &touch_event(TouchId(1), TouchPhase::Moved, 100., 150.),
1013            now + Duration::from_millis(32),
1014        );
1015        let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else {
1016            panic!("expected scroll, got {recognized:?}");
1017        };
1018        assert_eq!(scroll.touch_phase, TouchPhase::Started);
1019    }
1020
1021    #[test]
1022    fn flick_velocity_reflects_release_speed_not_window_average() {
1023        // A uniformly accelerating flick: position grows quadratically, so
1024        // the speed at the newest sample (2·k·t) is twice the window
1025        // average (k·t). The estimator must report the former.
1026        let mut velocity_tracker = VelocityTracker::default();
1027        let start = Instant::now();
1028        for step in 0..=6 {
1029            let t = step as f32 * 0.016;
1030            velocity_tracker.push(
1031                start + Duration::from_millis(step * 16),
1032                point(px(0.), px(1000. * t * t)),
1033            );
1034        }
1035        let velocity = velocity_tracker.velocity();
1036        let release_speed = 2. * 1000. * 0.096;
1037        assert!(
1038            (velocity.y - release_speed).abs() < 1.,
1039            "expected ≈{release_speed} px/s at release, got {} px/s",
1040            velocity.y
1041        );
1042        assert_eq!(velocity.x, 0.);
1043    }
1044
1045    #[test]
1046    fn samples_before_a_pause_do_not_contribute_velocity() {
1047        // Fast motion, then a hold longer than the stopped-finger gap, then
1048        // a slow nudge: only the motion after the pause describes the
1049        // release.
1050        let mut velocity_tracker = VelocityTracker::default();
1051        let start = Instant::now();
1052        velocity_tracker.push(start, point(px(0.), px(0.)));
1053        velocity_tracker.push(start + Duration::from_millis(16), point(px(0.), px(50.)));
1054        velocity_tracker.push(start + Duration::from_millis(80), point(px(0.), px(52.)));
1055        velocity_tracker.push(start + Duration::from_millis(96), point(px(0.), px(54.)));
1056        let velocity = velocity_tracker.velocity();
1057        assert!(
1058            velocity.y < 200.,
1059            "pre-pause motion leaked into the estimate: {} px/s",
1060            velocity.y
1061        );
1062    }
1063
1064    fn touch_event(id: TouchId, phase: TouchPhase, x: f32, y: f32) -> TouchEvent {
1065        TouchEvent {
1066            id,
1067            phase,
1068            position: point(px(x), px(y)),
1069            force: None,
1070        }
1071    }
1072}