Skip to main content

teksilo_core/gesture/
swipe.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::time::Instant;
5
6use teksilo_canvas::Point;
7
8use super::{GestureEvent, GestureRecognizer, GestureResult, RawPointerEvent, SwipeDirection};
9
10/// Recognizes a swipe gesture (quick directional movement above velocity threshold).
11#[derive(Debug)]
12pub struct SwipeRecognizer {
13    min_velocity: f32,
14    min_distance: f32,
15    max_cross_ratio: f32,
16    down_position: Option<Point>,
17    down_time: Option<Instant>,
18}
19
20impl SwipeRecognizer {
21    pub fn new() -> Self {
22        Self {
23            min_velocity: 200.0,  // pixels per second
24            min_distance: 30.0,   // minimum swipe distance
25            max_cross_ratio: 0.5, // max perpendicular/parallel ratio
26            down_position: None,
27            down_time: None,
28        }
29    }
30
31    pub fn min_velocity(mut self, v: f32) -> Self {
32        self.min_velocity = v;
33        self
34    }
35
36    pub fn min_distance(mut self, d: f32) -> Self {
37        self.min_distance = d;
38        self
39    }
40
41    /// Process with an explicit timestamp for testability.
42    pub fn process_at(&mut self, event: &RawPointerEvent, now: Instant) -> GestureResult {
43        match event {
44            RawPointerEvent::Down { position, .. } => {
45                self.down_position = Some(*position);
46                self.down_time = Some(now);
47                GestureResult::Pending
48            }
49            RawPointerEvent::Move { .. } => GestureResult::Pending,
50            RawPointerEvent::Up { position, .. } => {
51                let (Some(down), Some(time)) = (self.down_position, self.down_time) else {
52                    return GestureResult::Failed;
53                };
54
55                let dx = position.x - down.x;
56                let dy = position.y - down.y;
57                let dist = (dx * dx + dy * dy).sqrt();
58                let elapsed = now.duration_since(time).as_secs_f32();
59
60                if dist < self.min_distance || elapsed <= 0.0 {
61                    self.reset();
62                    return GestureResult::Failed;
63                }
64
65                let velocity = dist / elapsed;
66                if velocity < self.min_velocity {
67                    self.reset();
68                    return GestureResult::Failed;
69                }
70
71                let abs_dx = dx.abs();
72                let abs_dy = dy.abs();
73
74                // Determine primary axis and check cross-axis ratio
75                let (direction, cross_ratio) = if abs_dx >= abs_dy {
76                    let dir = if dx > 0.0 {
77                        SwipeDirection::Right
78                    } else {
79                        SwipeDirection::Left
80                    };
81                    (dir, abs_dy / abs_dx.max(0.001))
82                } else {
83                    let dir = if dy > 0.0 {
84                        SwipeDirection::Down
85                    } else {
86                        SwipeDirection::Up
87                    };
88                    (dir, abs_dx / abs_dy.max(0.001))
89                };
90
91                if cross_ratio > self.max_cross_ratio {
92                    self.reset();
93                    return GestureResult::Failed;
94                }
95
96                self.reset();
97                GestureResult::Recognized(GestureEvent::Swipe {
98                    direction,
99                    velocity,
100                })
101            }
102        }
103    }
104}
105
106impl Default for SwipeRecognizer {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl GestureRecognizer for SwipeRecognizer {
113    fn process(&mut self, event: &RawPointerEvent) -> GestureResult {
114        self.process_at(event, Instant::now())
115    }
116
117    fn reset(&mut self) {
118        self.down_position = None;
119        self.down_time = None;
120    }
121
122    fn priority(&self) -> u32 {
123        30 // High priority — swipe is decisive
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use std::time::Duration;
130
131    use super::*;
132    use crate::gesture::test_helpers::*;
133
134    #[test]
135    fn swipe_right_recognized() {
136        let mut rec = SwipeRecognizer::new()
137            .min_velocity(100.0)
138            .min_distance(20.0);
139        let t0 = Instant::now();
140
141        rec.process_at(&down(Point::new(10.0, 50.0)), t0);
142        let result = rec.process_at(
143            &up(Point::new(200.0, 55.0)),
144            t0 + Duration::from_millis(100),
145        );
146        match result {
147            GestureResult::Recognized(GestureEvent::Swipe {
148                direction,
149                velocity,
150            }) => {
151                assert_eq!(direction, SwipeDirection::Right);
152                assert!(velocity > 100.0);
153            }
154            other => panic!("Expected Swipe, got {:?}", other),
155        }
156    }
157
158    #[test]
159    fn swipe_left_recognized() {
160        let mut rec = SwipeRecognizer::new()
161            .min_velocity(100.0)
162            .min_distance(20.0);
163        let t0 = Instant::now();
164
165        rec.process_at(&down(Point::new(200.0, 50.0)), t0);
166        let result = rec.process_at(&up(Point::new(10.0, 55.0)), t0 + Duration::from_millis(100));
167        assert!(matches!(
168            result,
169            GestureResult::Recognized(GestureEvent::Swipe {
170                direction: SwipeDirection::Left,
171                ..
172            })
173        ));
174    }
175
176    #[test]
177    fn swipe_fails_if_too_slow() {
178        let mut rec = SwipeRecognizer::new().min_velocity(500.0);
179        let t0 = Instant::now();
180
181        rec.process_at(&down(Point::new(10.0, 10.0)), t0);
182        let result = rec.process_at(
183            &up(Point::new(50.0, 10.0)),
184            t0 + Duration::from_secs(5), // Very slow
185        );
186        assert!(matches!(result, GestureResult::Failed));
187    }
188
189    #[test]
190    fn swipe_fails_if_diagonal() {
191        let mut rec = SwipeRecognizer::new()
192            .min_velocity(100.0)
193            .min_distance(20.0);
194        let t0 = Instant::now();
195
196        rec.process_at(&down(Point::new(10.0, 10.0)), t0);
197        // Equal dx and dy — diagonal, cross_ratio = 1.0 > 0.5
198        let result = rec.process_at(
199            &up(Point::new(100.0, 100.0)),
200            t0 + Duration::from_millis(100),
201        );
202        assert!(matches!(result, GestureResult::Failed));
203    }
204}