1use 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#[derive(Clone, Copy, Debug, Default)]
29pub struct OngoingScroll {
30 last_event: Option<Instant>,
31 axis: Option<Axis>,
32}
33
34impl OngoingScroll {
35 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#[derive(Clone, Copy, Debug, PartialEq)]
100pub struct GestureTuning {
101 pub touch_slop: Pixels,
104 pub multi_tap_interval: Duration,
106 pub multi_tap_slop: Pixels,
108 pub long_press_duration: Duration,
111 pub momentum_decay_per_ms: f32,
115 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub struct GestureKinds {
140 pub tap: bool,
142 pub long_press: bool,
144 pub pan: bool,
147 pub pinch: bool,
149}
150
151impl GestureKinds {
152 pub const NONE: Self = Self {
154 tap: false,
155 long_press: false,
156 pan: false,
157 pinch: false,
158 };
159
160 pub const ALL: Self = Self {
162 tap: true,
163 long_press: true,
164 pan: true,
165 pinch: true,
166 };
167}
168
169#[derive(Clone, Debug, Default)]
177pub struct LongPressEvent {
178 pub position: Point<Pixels>,
180}
181
182pub trait PlatformGestures {
187 fn tuning(&self) -> GestureTuning {
189 GestureTuning::default()
190 }
191
192 fn native_recognizers(&self) -> GestureKinds {
194 GestureKinds::NONE
195 }
196}
197
198pub struct NullPlatformGestures;
201
202impl PlatformGestures for NullPlatformGestures {}
203
204const MAX_FLING_VELOCITY: f32 = 8000.;
207
208const MOMENTUM_STOP_VELOCITY: f32 = 10.;
211
212const MOMENTUM_MAX_TICK: Duration = Duration::from_millis(250);
218
219const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
222
223const VELOCITY_ASSUME_STOPPED_GAP: Duration = Duration::from_millis(40);
228
229const VELOCITY_MAX_SAMPLES: usize = 20;
230
231pub(crate) struct TouchGestureRecognizer {
244 tuning: GestureTuning,
245 state: TouchGestureState,
246 momentum: Option<Momentum>,
247 last_tap: Option<CompletedTap>,
248}
249
250#[derive(Debug)]
253pub(crate) enum RecognizedTouchGesture {
254 Scroll(ScrollWheelEvent),
257 Tap {
259 down: MouseDownEvent,
260 up: MouseUpEvent,
261 },
262}
263
264enum TouchGestureState {
265 Idle,
266 Pending(ActiveTouch),
269 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 position: Point<Pixels>,
291 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 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 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 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#[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 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(×_seconds, &horizontal).map_or_else(
577 || endpoint_estimate(&horizontal),
578 |velocity| velocity as f32,
579 ),
580 quadratic_velocity_at_newest(×_seconds, &vertical)
581 .map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32),
582 )
583 }
584}
585
586fn 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 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 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 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 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 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}