use crate::core::Point;
use crate::event::{Event, TouchId};
use super::{distance, point_delta, tuning, GestureKind, GestureRecognizer};
#[derive(Debug, Clone)]
pub struct SwipeGesture {
start_pos: Option<Point>,
start_time: Option<u64>,
last_pos: Option<Point>,
last_time: Option<u64>,
touch_id: Option<TouchId>,
locked: Option<(SwipeAxis, i32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SwipeAxis {
Horizontal,
Vertical,
}
impl SwipeGesture {
pub fn new() -> Self {
Self {
start_pos: None,
start_time: None,
last_pos: None,
last_time: None,
touch_id: None,
locked: None,
}
}
fn locked_direction(&self) -> Option<(SwipeAxis, i32)> {
self.locked
}
}
impl GestureRecognizer for SwipeGesture {
fn kind(&self) -> GestureKind {
GestureKind::Swipe
}
fn process(&mut self, event: &Event, now_ms: u64) -> Option<Event> {
match event {
Event::TouchBegin { pos, touch_id } => {
self.start_pos = Some(*pos);
self.start_time = Some(now_ms);
self.last_pos = Some(*pos);
self.last_time = Some(now_ms);
self.touch_id = Some(*touch_id);
self.locked = None;
None
}
Event::TouchMove { pos, touch_id } => {
if self.touch_id == Some(*touch_id) {
self.last_pos = Some(*pos);
self.last_time = Some(now_ms);
self.update_lock(*pos);
}
None
}
Event::TouchEnd { pos, touch_id } => {
if self.touch_id != Some(*touch_id) {
return None;
}
let start = self.start_pos?;
let start_time = self.start_time?;
let total_dist = distance(start, *pos);
if total_dist < tuning().swipe_min_distance {
self.reset();
return None;
}
if let Some((axis, sign)) = self.locked_direction() {
let main = match axis {
SwipeAxis::Horizontal => pos.x as i64 - start.x as i64,
SwipeAxis::Vertical => pos.y as i64 - start.y as i64,
};
if main != 0 && main.signum() as i32 != sign {
self.reset();
return None;
}
}
let dt = now_ms.saturating_sub(start_time);
let velocity = if dt > 0 { total_dist / dt as f32 * 1000.0 } else { 0.0 };
if velocity >= tuning().swipe_min_velocity {
let result = Event::Swipe { start, end: *pos, velocity };
self.reset();
return Some(result);
}
self.reset();
None
}
_ => None,
}
}
fn partial(&self) -> Option<Event> {
let _ = self.locked_direction()?;
let start = self.start_pos?;
let end = self.last_pos?;
let started_at = self.start_time?;
let last_at = self.last_time?;
let dt = last_at.saturating_sub(started_at);
let velocity = if dt > 0 { distance(start, end) / dt as f32 * 1000.0 } else { 0.0 };
Some(Event::Swipe { start, end, velocity })
}
fn reset(&mut self) {
self.start_pos = None;
self.start_time = None;
self.last_pos = None;
self.last_time = None;
self.touch_id = None;
self.locked = None;
}
}
impl SwipeGesture {
fn update_lock(&mut self, pos: Point) {
let Some(start) = self.start_pos else {
return;
};
let dx = pos.x as i64 - start.x as i64;
let dy = pos.y as i64 - start.y as i64;
match self.locked {
None => {
if distance(start, pos) < tuning().swipe_min_distance {
return;
}
let axis =
if dx.abs() >= dy.abs() { SwipeAxis::Horizontal } else { SwipeAxis::Vertical };
let main = match axis {
SwipeAxis::Horizontal => dx,
SwipeAxis::Vertical => dy,
};
if main != 0 {
self.locked = Some((axis, main.signum() as i32));
}
}
Some((axis, sign)) => {
let main = match axis {
SwipeAxis::Horizontal => dx,
SwipeAxis::Vertical => dy,
};
if main != 0 && main.signum() as i32 != sign {
self.reset();
}
}
}
}
}
crate::impl_default_via_new!(SwipeGesture);
#[derive(Debug)]
pub struct TwoFingerSwipeGesture {
touches: Vec<(Point, TouchId)>,
baseline: Option<(u64, Point)>,
touch_ends: Vec<Point>,
fingers: usize,
}
impl TwoFingerSwipeGesture {
pub fn new() -> Self {
Self { touches: Vec::new(), baseline: None, touch_ends: Vec::new(), fingers: 2 }
}
pub fn with_fingers(fingers: usize) -> Self {
Self { fingers: fingers.max(2), ..Self::new() }
}
pub fn fingers(&self) -> usize {
self.fingers
}
fn compute_centroid(touches: &[(Point, TouchId)]) -> Option<Point> {
if touches.is_empty() {
return None;
}
let mut sum_x: i64 = 0;
let mut sum_y: i64 = 0;
for (pos, _) in touches {
sum_x += pos.x as i64;
sum_y += pos.y as i64;
}
let count = touches.len() as i64;
Some(Point::new((sum_x / count) as i32, (sum_y / count) as i32))
}
fn centroid_of_points(points: &[Point]) -> Option<Point> {
if points.is_empty() {
return None;
}
let n = points.len() as i64;
let sum_x: i64 = points.iter().map(|p| p.x as i64).sum();
let sum_y: i64 = points.iter().map(|p| p.y as i64).sum();
Some(Point::new((sum_x / n) as i32, (sum_y / n) as i32))
}
}
impl GestureRecognizer for TwoFingerSwipeGesture {
fn kind(&self) -> GestureKind {
GestureKind::TwoFingerSwipe
}
fn process(&mut self, event: &Event, now_ms: u64) -> Option<Event> {
match event {
Event::TouchBegin { pos, touch_id } => {
if self.touches.len() >= self.fingers {
return None;
}
self.touches.push((*pos, *touch_id));
if self.touches.len() == self.fingers {
if let Some(centroid) = Self::compute_centroid(&self.touches) {
self.baseline = Some((now_ms, centroid));
}
}
None
}
Event::TouchMove { pos, touch_id } => {
if let Some(t) = self.touches.iter_mut().find(|(_, id)| *id == *touch_id) {
t.0 = *pos;
}
None
}
Event::TouchEnd { pos, touch_id } => {
if let Some(t) = self.touches.iter_mut().find(|(_, id)| *id == *touch_id) {
t.0 = *pos;
self.touch_ends.push(*pos);
}
self.touches.retain(|(_, id)| *id != *touch_id);
if self.touches.is_empty() {
let result = if let (Some((started_at, start)), Some(end)) =
(self.baseline, Self::centroid_of_points(&self.touch_ends))
{
let dx = end.x as f64 - start.x as f64;
let dy = end.y as f64 - start.y as f64;
let dist = (dx * dx + dy * dy).sqrt() as f32;
let elapsed = now_ms.saturating_sub(started_at).max(1) as f32;
let velocity = dist / elapsed * 1000.0;
if dist >= tuning().swipe_min_distance
&& velocity >= tuning().swipe_min_velocity
{
if self.fingers == 2 {
Some(Event::TwoFingerSwipe {
centroid_start: start,
centroid_end: end,
velocity,
})
} else {
Some(Event::MultiFingerSwipe {
fingers: u8::try_from(self.fingers).unwrap_or(u8::MAX),
centroid_start: start,
centroid_end: end,
velocity,
})
}
} else {
None
}
} else {
None
};
self.reset();
return result;
}
None
}
_ => None,
}
}
fn reset(&mut self) {
self.touches.clear();
self.baseline = None;
self.touch_ends.clear();
}
}
crate::impl_default_via_new!(TwoFingerSwipeGesture);
const VELOCITY_WINDOW_MS: u64 = 100;
const FLING_SAMPLE_CAPACITY: usize = 64;
#[derive(Debug, Clone)]
struct SampleRing {
samples: [(Point, u64); FLING_SAMPLE_CAPACITY],
head: usize,
len: usize,
}
impl SampleRing {
fn new() -> Self {
Self { samples: [(Point::new(0, 0), 0); FLING_SAMPLE_CAPACITY], head: 0, len: 0 }
}
fn clear(&mut self) {
self.head = 0;
self.len = 0;
}
fn push(&mut self, pos: Point, time_ms: u64) {
if self.len < FLING_SAMPLE_CAPACITY {
let index = (self.head + self.len) % FLING_SAMPLE_CAPACITY;
self.samples[index] = (pos, time_ms);
self.len += 1;
} else {
self.samples[self.head] = (pos, time_ms);
self.head = (self.head + 1) % FLING_SAMPLE_CAPACITY;
}
}
fn get(&self, index: usize) -> (Point, u64) {
self.samples[(self.head + index) % FLING_SAMPLE_CAPACITY]
}
fn retain_since(&mut self, cutoff: u64) {
while self.len > 0 && self.get(0).1 < cutoff {
self.head = (self.head + 1) % FLING_SAMPLE_CAPACITY;
self.len -= 1;
}
}
}
#[derive(Debug)]
pub struct FlingGesture {
start_pos: Option<Point>,
start_time: Option<u64>,
touch_id: Option<TouchId>,
samples: SampleRing,
}
impl FlingGesture {
pub fn new() -> Self {
Self { start_pos: None, start_time: None, touch_id: None, samples: SampleRing::new() }
}
fn compute_velocity(&self) -> Option<Point> {
let (_, now) = self.samples.get(self.samples.len.checked_sub(1)?);
let cutoff = now.saturating_sub(VELOCITY_WINDOW_MS);
let mut first_index = 0;
while first_index < self.samples.len && self.samples.get(first_index).1 < cutoff {
first_index += 1;
}
if self.samples.len - first_index < 2 {
return None;
}
let first = self.samples.get(first_index);
let last = self.samples.get(self.samples.len - 1);
let dt = last.1.saturating_sub(first.1).max(1) as f32;
let (raw_dx, raw_dy) = point_delta(first.0, last.0);
let dx = raw_dx as f32;
let dy = raw_dy as f32;
let vx = (dx / dt * 1000.0).clamp(i32::MIN as f32, i32::MAX as f32) as i32;
let vy = (dy / dt * 1000.0).clamp(i32::MIN as f32, i32::MAX as f32) as i32;
Some(Point::new(vx, vy))
}
}
impl GestureRecognizer for FlingGesture {
fn kind(&self) -> GestureKind {
GestureKind::Fling
}
fn process(&mut self, event: &Event, now_ms: u64) -> Option<Event> {
match event {
Event::TouchBegin { pos, touch_id } if self.start_pos.is_none() => {
self.start_pos = Some(*pos);
self.start_time = Some(now_ms);
self.touch_id = Some(*touch_id);
self.samples.clear();
self.samples.push(*pos, now_ms);
None
}
Event::TouchMove { pos, touch_id } if self.touch_id == Some(*touch_id) => {
self.samples.push(*pos, now_ms);
let cutoff = now_ms.saturating_sub(VELOCITY_WINDOW_MS * 2);
self.samples.retain_since(cutoff);
None
}
Event::TouchEnd { pos, touch_id } if self.touch_id == Some(*touch_id) => {
self.samples.push(*pos, now_ms);
let velocity = self.compute_velocity();
let total_distance = if let Some(start) = self.start_pos {
let dx = pos.x as f64 - start.x as f64;
let dy = pos.y as f64 - start.y as f64;
(dx * dx + dy * dy).sqrt() as f32
} else {
0.0
};
let speed = if let Some(v) = velocity {
let vx = v.x as f64;
let vy = v.y as f64;
(vx * vx + vy * vy).sqrt() as f32
} else {
0.0
};
self.reset();
let tuning = tuning();
if total_distance >= tuning.fling_min_distance && speed >= tuning.fling_min_velocity
{
Some(Event::Fling {
pos: *pos,
velocity: velocity.unwrap_or(Point::new(0, 0)),
touch_id: *touch_id,
})
} else {
None
}
}
_ => None,
}
}
fn reset(&mut self) {
self.start_pos = None;
self.start_time = None;
self.touch_id = None;
self.samples.clear();
}
}
crate::impl_default_via_new!(FlingGesture);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_two_finger_swipe_velocity_uses_the_real_elapsed_time() {
let mut gesture = TwoFingerSwipeGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 100), touch_id: 2 }, 0)
.is_none());
let moved = Point::new(200, 50);
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(200, 0), touch_id: 1 }, 200)
.is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(200, 100), touch_id: 2 }, 200)
.is_none());
assert!(gesture.process(&Event::TouchEnd { pos: moved, touch_id: 1 }, 200).is_none());
let Some(Event::TwoFingerSwipe { centroid_start, centroid_end, velocity }) =
gesture.process(&Event::TouchEnd { pos: moved, touch_id: 2 }, 200)
else {
panic!("a 200px two-finger travel must produce a swipe");
};
assert_eq!(centroid_start, Point::new(0, 50));
assert_eq!(centroid_end, Point::new(200, 50));
assert!(
(velocity - 1000.0).abs() < 0.5,
"the velocity must use the real 200 ms elapsed time, not the clamp; got {velocity}"
);
}
#[test]
fn a_single_finger_never_produces_a_two_finger_swipe() {
let mut gesture = TwoFingerSwipeGesture::new();
let _ = gesture.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0);
let _ = gesture.process(&Event::TouchMove { pos: Point::new(500, 0), touch_id: 1 }, 100);
assert!(gesture
.process(&Event::TouchEnd { pos: Point::new(500, 0), touch_id: 1 }, 100)
.is_none());
}
#[test]
fn slow_long_drag_is_not_a_fling() {
let mut gesture = FlingGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(20, 0), touch_id: 1 }, 1000);
assert!(
produced.is_none(),
"a slow long drag must not be reported as a Fling, got {produced:?}"
);
}
#[test]
fn hold_then_release_is_not_a_fling() {
let mut gesture = FlingGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(30, 0), touch_id: 1 }, 50)
.is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(30, 0), touch_id: 1 }, 400)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(30, 0), touch_id: 1 }, 450);
assert!(produced.is_none(), "a hold-then-release must not be a Fling, got {produced:?}");
}
#[test]
fn short_fast_flick_is_a_fling() {
let mut gesture = FlingGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(20, 0), touch_id: 1 }, 50);
match produced {
Some(Event::Fling { velocity, .. }) => assert_eq!(velocity, Point::new(400, 0)),
other => panic!("a short fast flick must be a Fling, got {other:?}"),
}
}
#[test]
fn fling_velocity_magnitude_does_not_overflow_i32() {
let mut gesture = FlingGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
let produced = gesture.process(&Event::TouchEnd { pos: Point::new(50, 0), touch_id: 1 }, 1);
match produced {
Some(Event::Fling { velocity, .. }) => {
assert_eq!(velocity.x, 50000);
assert_eq!(velocity.y, 0);
}
other => panic!("a fast flick must still be a Fling, got {other:?}"),
}
}
#[test]
fn two_finger_swipe_distance_does_not_overflow_i32() {
let mut gesture = TwoFingerSwipeGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 100), touch_id: 2 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchEnd { pos: Point::new(50000, 0), touch_id: 1 }, 100)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(50000, 100), touch_id: 2 }, 100);
assert!(
matches!(produced, Some(Event::TwoFingerSwipe { .. })),
"large-coordinate travel must produce a swipe, got {produced:?}"
);
}
#[test]
fn two_finger_swipe_uses_release_positions_without_moves() {
let mut gesture = TwoFingerSwipeGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 100), touch_id: 2 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchEnd { pos: Point::new(200, 0), touch_id: 1 }, 100)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(200, 100), touch_id: 2 }, 100);
match produced {
Some(Event::TwoFingerSwipe { centroid_start, centroid_end, .. }) => {
assert_eq!(centroid_start, Point::new(0, 50));
assert_eq!(centroid_end, Point::new(200, 50));
}
other => panic!("a release without moves must still produce a swipe, got {other:?}"),
}
}
#[test]
fn two_finger_swipe_release_positions_override_last_move() {
let mut gesture = TwoFingerSwipeGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 100), touch_id: 2 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(100, 0), touch_id: 1 }, 50)
.is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(100, 100), touch_id: 2 }, 50)
.is_none());
assert!(gesture
.process(&Event::TouchEnd { pos: Point::new(200, 0), touch_id: 1 }, 100)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(200, 100), touch_id: 2 }, 100);
match produced {
Some(Event::TwoFingerSwipe { centroid_end, .. }) => {
assert_eq!(
centroid_end,
Point::new(200, 50),
"release must override the last move"
);
}
other => panic!("expected TwoFingerSwipe, got {other:?}"),
}
}
#[test]
fn two_finger_swipe_release_order_is_consistent() {
let mut gesture = TwoFingerSwipeGesture::new();
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: 1 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 100), touch_id: 2 }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchEnd { pos: Point::new(200, 100), touch_id: 2 }, 100)
.is_none());
let produced =
gesture.process(&Event::TouchEnd { pos: Point::new(200, 0), touch_id: 1 }, 100);
match produced {
Some(Event::TwoFingerSwipe { centroid_end, .. }) => {
assert_eq!(centroid_end, Point::new(200, 50));
}
other => panic!("reversed release order must still produce a swipe, got {other:?}"),
}
}
#[test]
fn a_swipe_locks_its_axis_and_previews_before_release() {
let mut gesture = SwipeGesture::new();
let id = 1;
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: id }, 0)
.is_none());
assert!(gesture.partial().is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(10, 1), touch_id: id }, 10)
.is_none());
assert!(gesture.partial().is_none(), "a sub-threshold drag has no direction");
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(80, 3), touch_id: id }, 20)
.is_none());
match gesture.partial() {
Some(Event::Swipe { start, end, velocity }) => {
assert_eq!(start, Point::new(0, 0));
assert_eq!(end, Point::new(80, 3));
assert!(velocity > 0.0, "the preview must carry the current speed, got {velocity}");
}
other => panic!("a locked swipe must preview as a Swipe, got {other:?}"),
}
match gesture.process(&Event::TouchEnd { pos: Point::new(160, 4), touch_id: id }, 30) {
Some(Event::Swipe { start, end, .. }) => {
assert_eq!(start, Point::new(0, 0));
assert_eq!(end, Point::new(160, 4));
}
other => panic!("the release is still the final confirmation, got {other:?}"),
}
}
#[test]
fn a_swipe_that_reverses_is_cancelled() {
let mut gesture = SwipeGesture::new();
let id = 1;
assert!(gesture
.process(&Event::TouchBegin { pos: Point::new(0, 0), touch_id: id }, 0)
.is_none());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(100, 0), touch_id: id }, 10)
.is_none());
assert!(gesture.partial().is_some());
assert!(gesture
.process(&Event::TouchMove { pos: Point::new(-10, 0), touch_id: id }, 20)
.is_none());
assert!(gesture.partial().is_none(), "a cancelled attempt has no preview");
assert!(gesture
.process(&Event::TouchEnd { pos: Point::new(-10, 0), touch_id: id }, 30)
.is_none());
}
#[test]
fn the_fling_sample_ring_keeps_the_newest_window_in_order() {
let mut ring = SampleRing::new();
for i in 0..5u64 {
ring.push(Point::new(i as i32, 0), i * 10);
}
assert_eq!(ring.len, 5);
assert_eq!(ring.get(0), (Point::new(0, 0), 0));
assert_eq!(ring.get(4), (Point::new(4, 0), 40));
ring.retain_since(20);
assert_eq!(ring.len, 3);
assert_eq!(ring.get(0), (Point::new(2, 0), 20));
assert_eq!(ring.get(2), (Point::new(4, 0), 40));
for i in 5..(FLING_SAMPLE_CAPACITY as u64 + 5) {
ring.push(Point::new(i as i32, 0), i * 10);
}
assert_eq!(ring.len, FLING_SAMPLE_CAPACITY);
let oldest = ring.get(0);
let newest = ring.get(FLING_SAMPLE_CAPACITY - 1);
assert!(oldest.1 <= newest.1, "the ring must stay oldest-to-newest");
assert_eq!(newest.1, (FLING_SAMPLE_CAPACITY as u64 + 4) * 10);
}
}
#[cfg(test)]
mod multi_finger_tests {
use super::*;
#[test]
fn three_fingers_emit_the_multi_finger_variant() {
let mut gesture = TwoFingerSwipeGesture::with_fingers(3);
assert_eq!(gesture.fingers(), 3);
for (index, (x, y)) in [(0, 0), (0, 10), (0, 20)].iter().enumerate() {
assert!(gesture
.process(
&Event::TouchBegin { pos: Point::new(*x, *y), touch_id: index as u64 + 1 },
0,
)
.is_none());
}
let mut produced = None;
for (index, y) in [0, 10, 20].iter().enumerate() {
produced = gesture.process(
&Event::TouchEnd { pos: Point::new(200, *y), touch_id: index as u64 + 1 },
100,
);
}
match produced {
Some(Event::MultiFingerSwipe { fingers, centroid_start, centroid_end, velocity }) => {
assert_eq!(fingers, 3);
assert_eq!(centroid_start, Point::new(0, 10));
assert_eq!(centroid_end, Point::new(200, 10));
assert!(velocity >= 500.0, "200 px in 100 ms is 2000 px/s, got {velocity}");
}
other => panic!("three fingers must emit a multi-finger swipe, got {other:?}"),
}
}
#[test]
fn the_finger_count_is_clamped_to_at_least_two() {
assert_eq!(TwoFingerSwipeGesture::with_fingers(0).fingers(), 2);
assert_eq!(TwoFingerSwipeGesture::with_fingers(1).fingers(), 2);
assert_eq!(TwoFingerSwipeGesture::with_fingers(4).fingers(), 4);
}
}