Skip to main content

teksilo_core/gesture/
drag.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_canvas::{Point, Vec2};
5
6use crate::event::PointerButton;
7
8use super::{GestureEvent, GestureRecognizer, GestureResult, RawPointerEvent};
9
10/// Recognizes a drag gesture (pointer down + move beyond threshold).
11#[derive(Debug)]
12pub struct DragRecognizer {
13    threshold: f32,
14    down_position: Option<Point>,
15    down_button: Option<PointerButton>,
16    dragging: bool,
17    last_position: Option<Point>,
18}
19
20impl DragRecognizer {
21    pub fn new() -> Self {
22        Self {
23            threshold: 5.0,
24            down_position: None,
25            down_button: None,
26            dragging: false,
27            last_position: None,
28        }
29    }
30
31    pub fn threshold(mut self, t: f32) -> Self {
32        self.threshold = t;
33        self
34    }
35}
36
37impl Default for DragRecognizer {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl GestureRecognizer for DragRecognizer {
44    fn process(&mut self, event: &RawPointerEvent) -> GestureResult {
45        match event {
46            RawPointerEvent::Down {
47                position, button, ..
48            } => {
49                self.down_position = Some(*position);
50                self.down_button = Some(*button);
51                self.last_position = Some(*position);
52                self.dragging = false;
53                GestureResult::Pending
54            }
55            RawPointerEvent::Move { position } => {
56                let Some(down) = self.down_position else {
57                    return GestureResult::Pending;
58                };
59
60                if self.dragging {
61                    let last = self.last_position.unwrap_or(down);
62                    let delta = Vec2::new(position.x - last.x, position.y - last.y);
63                    self.last_position = Some(*position);
64                    return GestureResult::Recognized(GestureEvent::DragMoved {
65                        position: *position,
66                        delta,
67                    });
68                }
69
70                let dx = position.x - down.x;
71                let dy = position.y - down.y;
72                if (dx * dx + dy * dy).sqrt() >= self.threshold {
73                    self.dragging = true;
74                    self.last_position = Some(*position);
75                    // `DragStarted.position` reports the *initial press*
76                    // (where `down_position` was stored on `Down`), not the
77                    // threshold-crossing position. Widgets that need to
78                    // classify where the grab originated (e.g. ScrollBar's
79                    // thumb-vs-track hit test) can rely on this; widgets
80                    // that want the current pointer will get it in the
81                    // immediately-following `DragMoved`.
82                    return GestureResult::Recognized(GestureEvent::DragStarted {
83                        position: down,
84                        button: self.down_button.unwrap_or(PointerButton::Primary),
85                    });
86                }
87
88                GestureResult::Pending
89            }
90            RawPointerEvent::Up { position, .. } => {
91                if self.dragging {
92                    self.dragging = false;
93                    self.down_position = None;
94                    return GestureResult::Recognized(GestureEvent::DragEnded {
95                        position: *position,
96                    });
97                }
98                self.down_position = None;
99                GestureResult::Failed
100            }
101        }
102    }
103
104    fn reset(&mut self) {
105        self.down_position = None;
106        self.down_button = None;
107        self.dragging = false;
108        self.last_position = None;
109    }
110
111    fn priority(&self) -> u32 {
112        20
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::gesture::test_helpers::*;
120
121    #[test]
122    fn drag_recognizer_fires_after_threshold() {
123        let mut rec = DragRecognizer::new().threshold(5.0);
124
125        assert!(matches!(
126            rec.process(&down(Point::new(10.0, 10.0))),
127            GestureResult::Pending
128        ));
129
130        // Small move — still pending
131        assert!(matches!(
132            rec.process(&move_to(Point::new(12.0, 10.0))),
133            GestureResult::Pending
134        ));
135
136        // Large move — drag started
137        assert!(matches!(
138            rec.process(&move_to(Point::new(20.0, 10.0))),
139            GestureResult::Recognized(GestureEvent::DragStarted { .. })
140        ));
141    }
142
143    #[test]
144    fn drag_emits_moved_and_ended() {
145        let mut rec = DragRecognizer::new().threshold(1.0);
146        rec.process(&down(Point::new(0.0, 0.0)));
147        // Cross threshold
148        rec.process(&move_to(Point::new(10.0, 0.0)));
149
150        // Subsequent move
151        let result = rec.process(&move_to(Point::new(15.0, 0.0)));
152        assert!(matches!(
153            result,
154            GestureResult::Recognized(GestureEvent::DragMoved { .. })
155        ));
156
157        // Release
158        let result = rec.process(&up(Point::new(15.0, 0.0)));
159        assert!(matches!(
160            result,
161            GestureResult::Recognized(GestureEvent::DragEnded { .. })
162        ));
163    }
164
165    #[test]
166    fn drag_fails_on_up_without_movement() {
167        let mut rec = DragRecognizer::new().threshold(5.0);
168        rec.process(&down(Point::new(10.0, 10.0)));
169        assert!(matches!(
170            rec.process(&up(Point::new(10.0, 10.0))),
171            GestureResult::Failed
172        ));
173    }
174
175    #[test]
176    fn reset_clears_state() {
177        let mut rec = DragRecognizer::new().threshold(1.0);
178        rec.process(&down(Point::new(0.0, 0.0)));
179        rec.process(&move_to(Point::new(10.0, 0.0)));
180        rec.reset();
181
182        // After reset, move without down should be pending (not drag)
183        assert!(matches!(
184            rec.process(&move_to(Point::new(20.0, 0.0))),
185            GestureResult::Pending
186        ));
187    }
188}