Skip to main content

teksilo_core/gesture/
multi_tap.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::time::{Duration, Instant};
5
6use teksilo_canvas::Point;
7
8use crate::event::{ButtonMask, PointerButton};
9
10use super::{GestureEvent, GestureRecognizer, GestureResult, RawPointerEvent, TapEvent, distance};
11
12/// Recognizes a double-tap (two taps within a time window and distance,
13/// using the same button).
14///
15/// Default `accept` is [`ButtonMask::PRIMARY`]; presses on other buttons
16/// are ignored. Within the recognized sequence, both taps must match
17/// the press button — a `Primary` then `Secondary` sequence resets to
18/// the new tap as a fresh "first" rather than firing `DoubleTap`.
19#[derive(Debug)]
20pub struct DoubleTapRecognizer {
21    max_distance: f32,
22    max_interval: Duration,
23    accept: ButtonMask,
24    first_tap_position: Option<Point>,
25    first_tap_time: Option<Instant>,
26    first_tap_button: Option<PointerButton>,
27    down_position: Option<Point>,
28    down_button: Option<PointerButton>,
29}
30
31impl DoubleTapRecognizer {
32    pub fn new() -> Self {
33        Self {
34            max_distance: 10.0,
35            max_interval: Duration::from_millis(300),
36            accept: ButtonMask::PRIMARY,
37            first_tap_position: None,
38            first_tap_time: None,
39            first_tap_button: None,
40            down_position: None,
41            down_button: None,
42        }
43    }
44
45    pub fn max_distance(mut self, d: f32) -> Self {
46        self.max_distance = d;
47        self
48    }
49
50    pub fn max_interval(mut self, interval: Duration) -> Self {
51        self.max_interval = interval;
52        self
53    }
54
55    /// Restrict (or extend) the set of buttons that can fire this
56    /// recognizer. Default is [`ButtonMask::PRIMARY`].
57    pub fn accept_buttons(mut self, mask: impl Into<ButtonMask>) -> Self {
58        self.accept = mask.into();
59        self
60    }
61
62    /// Convenience: accept any pointer button.
63    pub fn accept_any_button(self) -> Self {
64        self.accept_buttons(ButtonMask::ALL)
65    }
66
67    /// Feed an event with an explicit timestamp (for testability without real clocks).
68    pub fn process_at(&mut self, event: &RawPointerEvent, now: Instant) -> GestureResult {
69        match event {
70            RawPointerEvent::Down {
71                position, button, ..
72            } => {
73                if !self.accept.contains(*button) {
74                    return GestureResult::Pending;
75                }
76                // Cross-tap button-match: if we have a first tap from a
77                // different button, the new press starts fresh — reset
78                // the accumulated state to avoid spuriously firing a
79                // mixed-button DoubleTap.
80                if let Some(first_button) = self.first_tap_button
81                    && first_button != *button
82                {
83                    self.first_tap_position = None;
84                    self.first_tap_time = None;
85                    self.first_tap_button = None;
86                }
87                self.down_position = Some(*position);
88                self.down_button = Some(*button);
89                GestureResult::Pending
90            }
91            RawPointerEvent::Move { position } => {
92                if let Some(down) = self.down_position
93                    && distance(*position, down) > self.max_distance
94                {
95                    return GestureResult::Failed;
96                }
97                GestureResult::Pending
98            }
99            RawPointerEvent::Up {
100                position,
101                button,
102                modifiers,
103            } => {
104                let Some(down) = self.down_position else {
105                    return GestureResult::Failed;
106                };
107                let Some(down_button) = self.down_button else {
108                    return GestureResult::Failed;
109                };
110                self.down_position = None;
111                self.down_button = None;
112                if *button != down_button {
113                    return GestureResult::Failed;
114                }
115                if distance(*position, down) > self.max_distance {
116                    return GestureResult::Failed;
117                }
118
119                if let (Some(first_pos), Some(first_time), Some(first_button)) = (
120                    self.first_tap_position,
121                    self.first_tap_time,
122                    self.first_tap_button,
123                ) {
124                    // Second tap — check distance, time interval, AND
125                    // button match against the first tap.
126                    if first_button == *button
127                        && distance(*position, first_pos) <= self.max_distance
128                        && now.duration_since(first_time) <= self.max_interval
129                    {
130                        self.reset();
131                        return GestureResult::Recognized(GestureEvent::DoubleTap(TapEvent {
132                            position: *position,
133                            button: *button,
134                            modifiers: *modifiers,
135                        }));
136                    }
137                    // Out of window or button mismatch — treat as new
138                    // first tap.
139                    self.first_tap_position = Some(*position);
140                    self.first_tap_time = Some(now);
141                    self.first_tap_button = Some(*button);
142                    GestureResult::Pending
143                } else {
144                    // First tap — record and wait for second.
145                    self.first_tap_position = Some(*position);
146                    self.first_tap_time = Some(now);
147                    self.first_tap_button = Some(*button);
148                    GestureResult::Pending
149                }
150            }
151        }
152    }
153}
154
155impl Default for DoubleTapRecognizer {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161impl GestureRecognizer for DoubleTapRecognizer {
162    fn process(&mut self, event: &RawPointerEvent) -> GestureResult {
163        self.process_at(event, Instant::now())
164    }
165
166    fn reset(&mut self) {
167        self.first_tap_position = None;
168        self.first_tap_time = None;
169        self.first_tap_button = None;
170        self.down_position = None;
171        self.down_button = None;
172    }
173
174    fn priority(&self) -> u32 {
175        15 // Higher than tap — double-tap should win over single tap
176    }
177
178    fn resets_on_peer_recognition(&self) -> bool {
179        // Cooperative with `TripleTapRecognizer`: when we fire a DoubleTap
180        // at click 2, the triple-tap recognizer may still be mid-sequence
181        // waiting for click 3. The arena must not wipe triple-tap state
182        // because of our win, and symmetrically we don't want our state
183        // wiped by a triple-tap's win either (though we've already reset
184        // ourselves internally by then).
185        false
186    }
187}
188
189/// Recognizes a triple tap (three taps within a time window and
190/// distance, all using the same button).
191///
192/// State machine mirrors `DoubleTapRecognizer` with one extra step:
193/// Idle → FirstTapLanded → SecondTapLanded → Recognized(TripleTap).
194/// Defaults match `DoubleTapRecognizer` (300 ms / 10 px / Primary only)
195/// so the two fire as a natural escalating pair. Mixed-button sequences
196/// reset to a fresh first tap.
197#[derive(Debug)]
198pub struct TripleTapRecognizer {
199    max_distance: f32,
200    max_interval: Duration,
201    accept: ButtonMask,
202    first_tap_position: Option<Point>,
203    first_tap_time: Option<Instant>,
204    first_tap_button: Option<PointerButton>,
205    second_tap_position: Option<Point>,
206    second_tap_time: Option<Instant>,
207    second_tap_button: Option<PointerButton>,
208    down_position: Option<Point>,
209    down_button: Option<PointerButton>,
210}
211
212impl TripleTapRecognizer {
213    pub fn new() -> Self {
214        Self {
215            max_distance: 10.0,
216            max_interval: Duration::from_millis(300),
217            accept: ButtonMask::PRIMARY,
218            first_tap_position: None,
219            first_tap_time: None,
220            first_tap_button: None,
221            second_tap_position: None,
222            second_tap_time: None,
223            second_tap_button: None,
224            down_position: None,
225            down_button: None,
226        }
227    }
228
229    pub fn max_distance(mut self, d: f32) -> Self {
230        self.max_distance = d;
231        self
232    }
233
234    pub fn max_interval(mut self, interval: Duration) -> Self {
235        self.max_interval = interval;
236        self
237    }
238
239    /// Restrict (or extend) the set of buttons that can fire this
240    /// recognizer. Default is [`ButtonMask::PRIMARY`].
241    pub fn accept_buttons(mut self, mask: impl Into<ButtonMask>) -> Self {
242        self.accept = mask.into();
243        self
244    }
245
246    /// Convenience: accept any pointer button.
247    pub fn accept_any_button(self) -> Self {
248        self.accept_buttons(ButtonMask::ALL)
249    }
250
251    /// Feed an event with an explicit timestamp (for testability without real clocks).
252    pub fn process_at(&mut self, event: &RawPointerEvent, now: Instant) -> GestureResult {
253        match event {
254            RawPointerEvent::Down {
255                position, button, ..
256            } => {
257                if !self.accept.contains(*button) {
258                    return GestureResult::Pending;
259                }
260                // Cross-tap button-match: if any accumulated tap used a
261                // different button, drop everything and start fresh.
262                let mismatch = self.first_tap_button.map(|b| b != *button).unwrap_or(false)
263                    || self
264                        .second_tap_button
265                        .map(|b| b != *button)
266                        .unwrap_or(false);
267                if mismatch {
268                    self.first_tap_position = None;
269                    self.first_tap_time = None;
270                    self.first_tap_button = None;
271                    self.second_tap_position = None;
272                    self.second_tap_time = None;
273                    self.second_tap_button = None;
274                }
275                self.down_position = Some(*position);
276                self.down_button = Some(*button);
277                GestureResult::Pending
278            }
279            RawPointerEvent::Move { position } => {
280                if let Some(down) = self.down_position
281                    && distance(*position, down) > self.max_distance
282                {
283                    return GestureResult::Failed;
284                }
285                GestureResult::Pending
286            }
287            RawPointerEvent::Up {
288                position,
289                button,
290                modifiers,
291            } => {
292                let Some(down) = self.down_position else {
293                    return GestureResult::Failed;
294                };
295                let Some(down_button) = self.down_button else {
296                    return GestureResult::Failed;
297                };
298                self.down_position = None;
299                self.down_button = None;
300                if *button != down_button {
301                    return GestureResult::Failed;
302                }
303                if distance(*position, down) > self.max_distance {
304                    return GestureResult::Failed;
305                }
306
307                // Third tap landed — this is the third if both prior
308                // timings AND buttons are in window/match.
309                if let (
310                    Some(first_pos),
311                    Some(first_time),
312                    Some(first_button),
313                    Some(second_pos),
314                    Some(second_time),
315                    Some(second_button),
316                ) = (
317                    self.first_tap_position,
318                    self.first_tap_time,
319                    self.first_tap_button,
320                    self.second_tap_position,
321                    self.second_tap_time,
322                    self.second_tap_button,
323                ) {
324                    if first_button == *button
325                        && second_button == *button
326                        && distance(*position, second_pos) <= self.max_distance
327                        && now.duration_since(second_time) <= self.max_interval
328                        && distance(second_pos, first_pos) <= self.max_distance
329                        && second_time.duration_since(first_time) <= self.max_interval
330                    {
331                        self.reset();
332                        return GestureResult::Recognized(GestureEvent::TripleTap(TapEvent {
333                            position: *position,
334                            button: *button,
335                            modifiers: *modifiers,
336                        }));
337                    }
338                    // Out of window or button mismatch: fold this tap
339                    // forward as a fresh first.
340                    self.first_tap_position = Some(*position);
341                    self.first_tap_time = Some(now);
342                    self.first_tap_button = Some(*button);
343                    self.second_tap_position = None;
344                    self.second_tap_time = None;
345                    self.second_tap_button = None;
346                    return GestureResult::Pending;
347                }
348
349                // First or second tap.
350                if let (Some(first_pos), Some(first_time), Some(first_button)) = (
351                    self.first_tap_position,
352                    self.first_tap_time,
353                    self.first_tap_button,
354                ) {
355                    // Second tap — if in window AND button matches, promote.
356                    if first_button == *button
357                        && distance(*position, first_pos) <= self.max_distance
358                        && now.duration_since(first_time) <= self.max_interval
359                    {
360                        self.second_tap_position = Some(*position);
361                        self.second_tap_time = Some(now);
362                        self.second_tap_button = Some(*button);
363                        return GestureResult::Pending;
364                    }
365                    // Out of window or mismatch — treat as fresh first.
366                    self.first_tap_position = Some(*position);
367                    self.first_tap_time = Some(now);
368                    self.first_tap_button = Some(*button);
369                    self.second_tap_position = None;
370                    self.second_tap_time = None;
371                    self.second_tap_button = None;
372                    return GestureResult::Pending;
373                }
374
375                // No prior tap — record as first.
376                self.first_tap_position = Some(*position);
377                self.first_tap_time = Some(now);
378                self.first_tap_button = Some(*button);
379                self.second_tap_position = None;
380                self.second_tap_time = None;
381                self.second_tap_button = None;
382                GestureResult::Pending
383            }
384        }
385    }
386}
387
388impl Default for TripleTapRecognizer {
389    fn default() -> Self {
390        Self::new()
391    }
392}
393
394impl GestureRecognizer for TripleTapRecognizer {
395    fn process(&mut self, event: &RawPointerEvent) -> GestureResult {
396        self.process_at(event, Instant::now())
397    }
398
399    fn reset(&mut self) {
400        self.first_tap_position = None;
401        self.first_tap_time = None;
402        self.first_tap_button = None;
403        self.second_tap_position = None;
404        self.second_tap_time = None;
405        self.second_tap_button = None;
406        self.down_position = None;
407        self.down_button = None;
408    }
409
410    fn priority(&self) -> u32 {
411        // Higher than DoubleTap so that when both would fire on the same
412        // up event (shouldn't happen in practice — TripleTap only fires
413        // after three taps and DoubleTap only at tap 2) TripleTap wins.
414        20
415    }
416
417    fn resets_on_peer_recognition(&self) -> bool {
418        // Cooperative with `DoubleTapRecognizer` — see the matching
419        // override on DoubleTapRecognizer. The arena must not wipe our
420        // accumulated first/second tap state when DoubleTap fires at
421        // click 2, or click 3 would never promote us to TripleTap.
422        false
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::event::Modifiers;
430    use crate::gesture::test_helpers::*;
431
432    // --- DoubleTapRecognizer ---
433
434    #[test]
435    fn double_tap_recognized_within_interval() {
436        let mut rec = DoubleTapRecognizer::new().max_interval(Duration::from_millis(500));
437        let t0 = Instant::now();
438        let p = Point::new(10.0, 10.0);
439
440        // First tap
441        rec.process_at(&down(p), t0);
442        rec.process_at(&up(p), t0 + Duration::from_millis(50));
443
444        // Second tap within interval
445        rec.process_at(
446            &down(Point::new(11.0, 10.0)),
447            t0 + Duration::from_millis(200),
448        );
449        let result = rec.process_at(&up(Point::new(11.0, 10.0)), t0 + Duration::from_millis(250));
450        assert!(matches!(
451            result,
452            GestureResult::Recognized(GestureEvent::DoubleTap(_))
453        ));
454    }
455
456    #[test]
457    fn double_tap_fails_if_too_slow() {
458        let mut rec = DoubleTapRecognizer::new().max_interval(Duration::from_millis(300));
459        let t0 = Instant::now();
460        let p = Point::new(10.0, 10.0);
461
462        rec.process_at(&down(p), t0);
463        rec.process_at(&up(p), t0 + Duration::from_millis(50));
464
465        rec.process_at(&down(p), t0 + Duration::from_millis(400));
466        let result = rec.process_at(&up(p), t0 + Duration::from_millis(450));
467        // Should be Pending (treated as new first tap), not Recognized
468        assert!(matches!(result, GestureResult::Pending));
469    }
470
471    #[test]
472    fn double_tap_fails_if_too_far() {
473        let mut rec = DoubleTapRecognizer::new().max_distance(5.0);
474        let t0 = Instant::now();
475        let p = Point::new(10.0, 10.0);
476
477        rec.process_at(&down(p), t0);
478        rec.process_at(&up(p), t0 + Duration::from_millis(50));
479
480        // Second tap too far from first
481        rec.process_at(
482            &down(Point::new(50.0, 50.0)),
483            t0 + Duration::from_millis(100),
484        );
485        let result = rec.process_at(&up(Point::new(50.0, 50.0)), t0 + Duration::from_millis(150));
486        // Treated as new first tap
487        assert!(matches!(result, GestureResult::Pending));
488    }
489
490    #[test]
491    fn double_tap_button_mismatch_fails_at_second_down() {
492        // First tap Primary, second tap Secondary → no DoubleTap. The
493        // second tap is recorded as a fresh first instead.
494        let mut rec = DoubleTapRecognizer::new().accept_any_button();
495        let t0 = Instant::now();
496        let p = Point::new(10.0, 10.0);
497
498        rec.process_at(&down_btn(p, PointerButton::Primary), t0);
499        rec.process_at(
500            &up_btn(p, PointerButton::Primary),
501            t0 + Duration::from_millis(50),
502        );
503
504        rec.process_at(
505            &down_btn(p, PointerButton::Secondary),
506            t0 + Duration::from_millis(150),
507        );
508        let result = rec.process_at(
509            &up_btn(p, PointerButton::Secondary),
510            t0 + Duration::from_millis(200),
511        );
512        assert!(matches!(result, GestureResult::Pending));
513    }
514
515    #[test]
516    fn double_tap_carries_modifiers_from_second_up() {
517        let mut rec = DoubleTapRecognizer::new();
518        let t0 = Instant::now();
519        let p = Point::new(10.0, 10.0);
520
521        rec.process_at(&down(p), t0);
522        rec.process_at(&up(p), t0 + Duration::from_millis(50));
523
524        rec.process_at(&down(p), t0 + Duration::from_millis(150));
525        let result = rec.process_at(
526            &up_full(p, PointerButton::Primary, Modifiers::SHIFT),
527            t0 + Duration::from_millis(200),
528        );
529        match result {
530            GestureResult::Recognized(GestureEvent::DoubleTap(event)) => {
531                assert!(event.modifiers.shift());
532            }
533            other => panic!("expected DoubleTap with shift, got {:?}", other),
534        }
535    }
536
537    // --- TripleTapRecognizer ---
538
539    #[test]
540    fn triple_tap_recognized_within_intervals() {
541        let mut rec = TripleTapRecognizer::new();
542        let t0 = Instant::now();
543        let p = Point::new(10.0, 10.0);
544
545        // Three taps all within window, all at (10, 10).
546        for i in 0..3 {
547            let offset = Duration::from_millis(200 * i as u64);
548            rec.process_at(&down(p), t0 + offset);
549            let result = rec.process_at(&up(p), t0 + offset + Duration::from_millis(50));
550            if i < 2 {
551                assert!(matches!(result, GestureResult::Pending));
552            } else {
553                assert!(matches!(
554                    result,
555                    GestureResult::Recognized(GestureEvent::TripleTap(_))
556                ));
557            }
558        }
559    }
560
561    #[test]
562    fn triple_tap_fails_if_third_is_too_slow() {
563        let mut rec = TripleTapRecognizer::new().max_interval(Duration::from_millis(300));
564        let t0 = Instant::now();
565        let stamp = |ms| t0 + Duration::from_millis(ms);
566        let p = Point::new(10.0, 10.0);
567
568        rec.process_at(&down(p), stamp(0));
569        rec.process_at(&up(p), stamp(50));
570        rec.process_at(&down(p), stamp(200));
571        rec.process_at(&up(p), stamp(250));
572
573        // Third tap > 300 ms after the second — does not recognize.
574        rec.process_at(&down(p), stamp(700));
575        let result = rec.process_at(&up(p), stamp(750));
576        assert!(matches!(result, GestureResult::Pending));
577    }
578
579    #[test]
580    fn triple_tap_fails_if_third_is_too_far() {
581        let mut rec = TripleTapRecognizer::new().max_distance(5.0);
582        let t0 = Instant::now();
583        let stamp = |ms| t0 + Duration::from_millis(ms);
584        let p = Point::new(10.0, 10.0);
585
586        rec.process_at(&down(p), stamp(0));
587        rec.process_at(&up(p), stamp(50));
588        rec.process_at(&down(p), stamp(100));
589        rec.process_at(&up(p), stamp(150));
590
591        // Third tap > 5 px from the second.
592        rec.process_at(&down(Point::new(30.0, 10.0)), stamp(200));
593        let result = rec.process_at(&up(Point::new(30.0, 10.0)), stamp(250));
594        assert!(matches!(result, GestureResult::Pending));
595    }
596
597    #[test]
598    fn triple_tap_button_mismatch_fails_at_third_down() {
599        // Third tap with a different button → no TripleTap. The
600        // second-tap state collapses and the new tap becomes a fresh
601        // first.
602        let mut rec = TripleTapRecognizer::new().accept_any_button();
603        let t0 = Instant::now();
604        let stamp = |ms| t0 + Duration::from_millis(ms);
605        let p = Point::new(10.0, 10.0);
606
607        rec.process_at(&down_btn(p, PointerButton::Primary), stamp(0));
608        rec.process_at(&up_btn(p, PointerButton::Primary), stamp(50));
609        rec.process_at(&down_btn(p, PointerButton::Primary), stamp(150));
610        rec.process_at(&up_btn(p, PointerButton::Primary), stamp(200));
611
612        rec.process_at(&down_btn(p, PointerButton::Secondary), stamp(300));
613        let result = rec.process_at(&up_btn(p, PointerButton::Secondary), stamp(350));
614        assert!(matches!(result, GestureResult::Pending));
615    }
616}