Skip to main content

teksilo_core/gesture/
tap.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_canvas::Point;
5
6use crate::event::{ButtonMask, PointerButton};
7
8use super::{GestureEvent, GestureRecognizer, GestureResult, RawPointerEvent, TapEvent};
9
10/// Recognizes a single tap (pointer down + up without significant movement).
11///
12/// The recognizer is button-aware: it only fires when the press button
13/// matches the release button, and only for buttons present in
14/// [`accept`](TapRecognizer::accept_buttons). Default `accept` is
15/// [`ButtonMask::PRIMARY`] — left-click only — which is what users
16/// expect from a "tap" and keeps right-click free for context menus.
17#[derive(Debug)]
18pub struct TapRecognizer {
19    max_distance: f32,
20    accept: ButtonMask,
21    down_position: Option<Point>,
22    down_button: Option<PointerButton>,
23}
24
25impl TapRecognizer {
26    pub fn new() -> Self {
27        Self {
28            max_distance: 5.0,
29            accept: ButtonMask::PRIMARY,
30            down_position: None,
31            down_button: None,
32        }
33    }
34
35    pub fn max_distance(mut self, d: f32) -> Self {
36        self.max_distance = d;
37        self
38    }
39
40    /// Restrict (or extend) the set of buttons that can fire this
41    /// recognizer. Default is [`ButtonMask::PRIMARY`].
42    pub fn accept_buttons(mut self, mask: impl Into<ButtonMask>) -> Self {
43        self.accept = mask.into();
44        self
45    }
46
47    /// Convenience: accept any pointer button.
48    pub fn accept_any_button(self) -> Self {
49        self.accept_buttons(ButtonMask::ALL)
50    }
51}
52
53impl Default for TapRecognizer {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl GestureRecognizer for TapRecognizer {
60    fn process(&mut self, event: &RawPointerEvent) -> GestureResult {
61        match event {
62            RawPointerEvent::Down {
63                position, button, ..
64            } => {
65                if !self.accept.contains(*button) {
66                    return GestureResult::Pending;
67                }
68                self.down_position = Some(*position);
69                self.down_button = Some(*button);
70                GestureResult::Pending
71            }
72            RawPointerEvent::Move { position } => {
73                if let Some(down) = self.down_position {
74                    let dx = position.x - down.x;
75                    let dy = position.y - down.y;
76                    if (dx * dx + dy * dy).sqrt() > self.max_distance {
77                        self.down_position = None;
78                        self.down_button = None;
79                        return GestureResult::Failed;
80                    }
81                }
82                GestureResult::Pending
83            }
84            RawPointerEvent::Up {
85                position,
86                button,
87                modifiers,
88            } => {
89                let Some(down) = self.down_position.take() else {
90                    return GestureResult::Failed;
91                };
92                let Some(down_button) = self.down_button.take() else {
93                    return GestureResult::Failed;
94                };
95                if *button != down_button {
96                    return GestureResult::Failed;
97                }
98                let dx = position.x - down.x;
99                let dy = position.y - down.y;
100                if (dx * dx + dy * dy).sqrt() <= self.max_distance {
101                    return GestureResult::Recognized(GestureEvent::Tap(TapEvent {
102                        position: *position,
103                        button: *button,
104                        modifiers: *modifiers,
105                    }));
106                }
107                GestureResult::Failed
108            }
109        }
110    }
111
112    fn reset(&mut self) {
113        self.down_position = None;
114        self.down_button = None;
115    }
116
117    fn priority(&self) -> u32 {
118        10
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::event::Modifiers;
126    use crate::gesture::test_helpers::*;
127
128    #[test]
129    fn tap_recognized_on_quick_down_up() {
130        let mut rec = TapRecognizer::new();
131        assert!(matches!(
132            rec.process(&down(Point::new(10.0, 10.0))),
133            GestureResult::Pending
134        ));
135        assert!(matches!(
136            rec.process(&up(Point::new(10.0, 10.0))),
137            GestureResult::Recognized(GestureEvent::Tap(_))
138        ));
139    }
140
141    #[test]
142    fn tap_fails_if_moved_too_far() {
143        let mut rec = TapRecognizer::new().max_distance(5.0);
144        rec.process(&down(Point::new(10.0, 10.0)));
145        assert!(matches!(
146            rec.process(&move_to(Point::new(20.0, 10.0))),
147            GestureResult::Failed
148        ));
149    }
150
151    #[test]
152    fn tap_default_filters_secondary_button() {
153        let mut rec = TapRecognizer::new();
154        // Right-click: Down is silently ignored (still Pending), Up
155        // sees no down_position recorded and fails — no Tap fires.
156        assert!(matches!(
157            rec.process(&down_btn(Point::new(10.0, 10.0), PointerButton::Secondary)),
158            GestureResult::Pending
159        ));
160        assert!(matches!(
161            rec.process(&up_btn(Point::new(10.0, 10.0), PointerButton::Secondary)),
162            GestureResult::Failed
163        ));
164    }
165
166    #[test]
167    fn tap_default_filters_middle_button() {
168        let mut rec = TapRecognizer::new();
169        assert!(matches!(
170            rec.process(&down_btn(Point::new(10.0, 10.0), PointerButton::Middle)),
171            GestureResult::Pending
172        ));
173        assert!(matches!(
174            rec.process(&up_btn(Point::new(10.0, 10.0), PointerButton::Middle)),
175            GestureResult::Failed
176        ));
177    }
178
179    #[test]
180    fn tap_accept_secondary_recognises_right_click() {
181        let mut rec = TapRecognizer::new().accept_buttons(ButtonMask::SECONDARY);
182        rec.process(&down_btn(Point::new(10.0, 10.0), PointerButton::Secondary));
183        match rec.process(&up_btn(Point::new(10.0, 10.0), PointerButton::Secondary)) {
184            GestureResult::Recognized(GestureEvent::Tap(event)) => {
185                assert_eq!(event.button, PointerButton::Secondary);
186            }
187            other => panic!("expected Tap on right-click, got {:?}", other),
188        }
189    }
190
191    #[test]
192    fn tap_button_mismatch_fails() {
193        let mut rec = TapRecognizer::new().accept_any_button();
194        rec.process(&down_btn(Point::new(10.0, 10.0), PointerButton::Primary));
195        // Up with a different button → Failed, no Tap.
196        assert!(matches!(
197            rec.process(&up_btn(Point::new(10.0, 10.0), PointerButton::Secondary)),
198            GestureResult::Failed
199        ));
200    }
201
202    #[test]
203    fn tap_carries_modifiers_from_up() {
204        let mut rec = TapRecognizer::new();
205        rec.process(&down_full(
206            Point::new(10.0, 10.0),
207            PointerButton::Primary,
208            Modifiers::NONE,
209        ));
210        match rec.process(&up_full(
211            Point::new(10.0, 10.0),
212            PointerButton::Primary,
213            Modifiers::CTRL | Modifiers::SHIFT,
214        )) {
215            GestureResult::Recognized(GestureEvent::Tap(event)) => {
216                assert!(event.modifiers.ctrl());
217                assert!(event.modifiers.shift());
218                assert!(!event.modifiers.alt());
219            }
220            other => panic!("expected Tap with modifiers, got {:?}", other),
221        }
222    }
223
224    #[test]
225    fn tap_recognised_for_forward_or_back_when_accepted() {
226        let mut rec = TapRecognizer::new().accept_buttons(ButtonMask::FORWARD | ButtonMask::BACK);
227        rec.process(&down_btn(Point::new(0.0, 0.0), PointerButton::Forward));
228        assert!(matches!(
229            rec.process(&up_btn(Point::new(0.0, 0.0), PointerButton::Forward)),
230            GestureResult::Recognized(GestureEvent::Tap(_))
231        ));
232        rec.process(&down_btn(Point::new(0.0, 0.0), PointerButton::Back));
233        assert!(matches!(
234            rec.process(&up_btn(Point::new(0.0, 0.0), PointerButton::Back)),
235            GestureResult::Recognized(GestureEvent::Tap(_))
236        ));
237    }
238}