Skip to main content

teksilo_core/gesture/
config.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! What a recognizer is told, and the per-node tap streak it reads.
5//!
6//! Before this module every recognizer carried its own copy of every
7//! threshold (`5.0` here, `300 ms` there) and several of them read
8//! `Instant::now()` in the middle of a state machine. That made two things
9//! impossible: tuning a gesture per pointer kind (a finger needs 18 dp of slop
10//! where a mouse needs 5), and testing a time-driven gesture without sleeping.
11//!
12//! Both are fixed by handing every `process` / `tick` call a
13//! [`RecognizerContext`]: the time comes from the tree's one
14//! [`InputClock`](crate::pointer::clock::InputClock), the thresholds come from
15//! the [`GestureProfile`] selected for *this* pointer's kind, and the
16//! recognizer keeps only the state that is genuinely its own.
17//!
18//! # The streak
19//!
20//! [`TapStreak`] is the one piece of tap state that is deliberately *not*
21//! recognizer-owned. A double tap spans two presses, and on a touchscreen the
22//! second press is a different [`PointerId`](crate::pointer::PointerId) — a
23//! new contact, a new arena. State held inside `DoubleTapRecognizer` would be
24//! destroyed between the two taps and touch double-tap would be structurally
25//! impossible. So the streak lives on the node, outlives every contact, and
26//! the recognizers only read it.
27
28use std::time::Duration;
29
30use teksilo_canvas::{Point, Rect};
31use teksilo_tokens::{GestureProfile, InputTokens, PointerKind, TargetDensity};
32
33use crate::event::PointerButton;
34use crate::pointer::{EventTime, PointerInfo};
35
36use super::{RawPointerEvent, distance};
37
38/// The token set a context-free caller falls back on — the shipped Compact
39/// ladder, whose mouse column is byte-for-byte the constants Teksilo shipped
40/// before the touch programme.
41const FALLBACK_INPUT: InputTokens = InputTokens::for_density(TargetDensity::Compact);
42
43/// The shipped [`GestureProfile`] for `kind`, for a caller with no theme in
44/// hand (a hand-rolled [`GestureArena`](super::GestureArena), a unit test).
45///
46/// The dispatch path does **not** use this: it reads the live theme's
47/// [`InputTokens`], so an app that retunes a profile retunes the recognizers.
48pub fn default_profile(kind: PointerKind) -> GestureProfile {
49    *FALLBACK_INPUT.profile(kind)
50}
51
52/// Everything a [`GestureRecognizer`](super::GestureRecognizer) is allowed to
53/// know beyond the event in front of it.
54///
55/// Rebuilt per dispatch rather than stored, so a theme change, a density
56/// change or a different pointer kind is picked up without touching a single
57/// recognizer.
58#[derive(Debug, Clone, Copy)]
59pub struct RecognizerContext<'a> {
60    /// Now, on the tree's input timeline. Never `Instant::now()` — see
61    /// [`EventTime`].
62    pub now: EventTime,
63    /// The thresholds for this pointer's kind. A recognizer reads its slop and
64    /// its timings from here unless the call site set an explicit override.
65    pub profile: GestureProfile,
66    /// The owning node's bounds in its own coordinate space (origin at zero),
67    /// for a recognizer that needs to know whether the pointer is still inside
68    /// the target it pressed.
69    pub local_bounds: Rect,
70    /// Which pointer produced the event being processed.
71    pub pointer: PointerInfo,
72    /// The owning node's tap streak. Read-only here: it is advanced by the
73    /// [`GestureArenaSet`](super::GestureArenaSet) that owns it, once per
74    /// qualifying release, *before* the recognizers see the event.
75    pub streak: &'a TapStreak,
76}
77
78/// The streak a context with no node behind it points at.
79static NO_STREAK: TapStreak = TapStreak::EMPTY;
80
81impl<'a> RecognizerContext<'a> {
82    /// A context with no streak behind it — for a recognizer driven directly
83    /// rather than through an arena set.
84    pub fn new(
85        now: EventTime,
86        profile: GestureProfile,
87        local_bounds: Rect,
88        pointer: PointerInfo,
89    ) -> RecognizerContext<'static> {
90        RecognizerContext {
91            now,
92            profile,
93            local_bounds,
94            pointer,
95            streak: &NO_STREAK,
96        }
97    }
98
99    /// The same context reading `streak` instead. Used by
100    /// [`GestureArenaSet`](super::GestureArenaSet) to splice its own streak in
101    /// without the caller having to own one.
102    pub fn with_streak<'b>(&self, streak: &'b TapStreak) -> RecognizerContext<'b> {
103        RecognizerContext {
104            now: self.now,
105            profile: self.profile,
106            local_bounds: self.local_bounds,
107            pointer: self.pointer,
108            streak,
109        }
110    }
111
112    /// The context a single event implies, with the shipped profile for its own
113    /// pointer kind and no bounds. What [`GestureArena::process`](super::GestureArena::process)
114    /// builds for a caller that supplies no node.
115    pub fn for_event(event: &RawPointerEvent) -> RecognizerContext<'static> {
116        let pointer = event.pointer();
117        Self::new(
118            event.time(),
119            default_profile(pointer.kind),
120            Rect::ZERO,
121            pointer,
122        )
123    }
124}
125
126/// The longest streak that means anything. A fourth tap inside the window
127/// restarts at one rather than growing without bound — the Qt convention, and
128/// the only one that keeps a long burst producing alternating double and
129/// triple taps.
130const MAX_STREAK: u8 = 3;
131
132/// How many taps in a row have landed on one node, and what the last of them
133/// looked like.
134///
135/// Owned by the node (through its [`GestureArenaSet`](super::GestureArenaSet)),
136/// **not** by a recognizer, because a tap streak outlives the contact that
137/// produced each tap. On a touchscreen each tap is a fresh
138/// [`PointerId`](crate::pointer::PointerId) and therefore a fresh arena; state
139/// kept inside `DoubleTapRecognizer` would be gone before the second tap
140/// arrived.
141///
142/// # Continuation rule
143///
144/// A tap continues the streak when **all** of these hold, and starts a new one
145/// (count 1) otherwise:
146///
147/// 1. it landed on the same node — true by construction, the streak *is* the
148///    node's;
149/// 2. it used the same button as the previous tap;
150/// 3. `now - last_up <= profile.multi_tap_interval`;
151/// 4. `distance(press point, last_position) <= profile.multi_tap_slop`;
152/// 5. no other gesture completed on the node in between — the arena set calls
153///    [`reset`](Self::reset) when a non-tap gesture wins.
154///
155/// Condition 4 measures **press to press**. The pre-P06 recognizers measured
156/// release to release; the two differ only by the within-tap travel, which is
157/// itself bounded by the tap slop, and pressing is the point the user aimed at.
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct TapStreak {
160    count: u8,
161    last_up: Option<EventTime>,
162    last_position: Point,
163    button: Option<PointerButton>,
164    /// The gap measured by the most recent [`advance`](Self::advance), or zero
165    /// when that advance started a fresh streak.
166    gap: Duration,
167    /// The press-to-press travel measured by the most recent
168    /// [`advance`](Self::advance), or zero when it started a fresh streak.
169    travel: f32,
170}
171
172impl TapStreak {
173    /// A streak with no taps in it.
174    pub const EMPTY: Self = Self {
175        count: 0,
176        last_up: None,
177        last_position: Point::ZERO,
178        button: None,
179        gap: Duration::ZERO,
180        travel: 0.0,
181    };
182
183    /// How many taps the current streak holds. `0` before the first tap,
184    /// `2` on the release that should fire a double tap, `3` on a triple.
185    pub fn count(&self) -> u8 {
186        self.count
187    }
188
189    /// When the streak's most recent tap was released.
190    pub fn last_up(&self) -> Option<EventTime> {
191        self.last_up
192    }
193
194    /// Where the streak's most recent tap was pressed.
195    pub fn last_position(&self) -> Point {
196        self.last_position
197    }
198
199    /// Which button the streak is running on.
200    pub fn button(&self) -> Option<PointerButton> {
201        self.button
202    }
203
204    /// The interval between the last two taps of the streak, or
205    /// [`Duration::ZERO`] when the last advance started a fresh one.
206    ///
207    /// A recognizer carrying an explicit — and therefore *tighter* — interval
208    /// override re-checks it against this. A **looser** override cannot widen
209    /// the window: the streak is the node's, and it uses the profile.
210    pub fn since_previous(&self) -> Duration {
211        self.gap
212    }
213
214    /// The press-to-press distance between the last two taps of the streak, or
215    /// `0.0` when the last advance started a fresh one. Same override rule as
216    /// [`since_previous`](Self::since_previous).
217    pub fn travel_from_previous(&self) -> f32 {
218        self.travel
219    }
220
221    /// Record a tap that has just been released, and return the new count.
222    ///
223    /// `position` is where the tap was **pressed** (see the continuation rule);
224    /// `now` is the release time.
225    pub fn advance(
226        &mut self,
227        now: EventTime,
228        profile: &GestureProfile,
229        position: Point,
230        button: PointerButton,
231    ) -> u8 {
232        let gap = self.last_up.map(|last| now.saturating_since(last));
233        let travel = self.last_up.map(|_| distance(position, self.last_position));
234        let continues = match (gap, travel, self.button) {
235            (Some(gap), Some(travel), Some(previous)) => {
236                previous == button
237                    && gap <= profile.multi_tap_interval
238                    && travel <= profile.multi_tap_slop
239            }
240            _ => false,
241        };
242
243        if continues && self.count < MAX_STREAK {
244            self.count += 1;
245            self.gap = gap.unwrap_or(Duration::ZERO);
246            self.travel = travel.unwrap_or(0.0);
247        } else {
248            self.count = 1;
249            self.gap = Duration::ZERO;
250            self.travel = 0.0;
251        }
252        self.last_up = Some(now);
253        self.last_position = position;
254        self.button = Some(button);
255        self.count
256    }
257
258    /// Break the streak. Called when a non-tap gesture completes on the node
259    /// (continuation rule 5), when a contact is cancelled, and when the tap
260    /// family is cancelled out from under the user.
261    pub fn reset(&mut self) {
262        *self = Self::EMPTY;
263    }
264}
265
266impl Default for TapStreak {
267    fn default() -> Self {
268        Self::EMPTY
269    }
270}
271
272/// Press bookkeeping for one contact, kept beside the recognizers so the
273/// "was that a tap?" question is answered once per contact instead of once per
274/// recognizer.
275///
276/// Mirrors what the multi-tap recognizers used to do inline: remember the press
277/// point and button, fail the moment the pointer strays past
278/// `multi_tap_slop`, and accept a release that lands within it on the button it
279/// started on.
280#[derive(Debug, Clone, Copy, Default)]
281pub(crate) struct TapContact {
282    press: Option<(Point, PointerButton)>,
283    strayed: bool,
284}
285
286impl TapContact {
287    /// Feed the contact an event; return the press point and button when this
288    /// event is a release that qualifies as a tap for streak purposes.
289    pub(crate) fn observe(
290        &mut self,
291        event: &RawPointerEvent,
292        profile: &GestureProfile,
293    ) -> Option<(Point, PointerButton)> {
294        match event {
295            RawPointerEvent::Down {
296                position, button, ..
297            } => {
298                self.press = Some((*position, *button));
299                self.strayed = false;
300                None
301            }
302            RawPointerEvent::Move { position, .. } => {
303                if let Some((press, _)) = self.press
304                    && distance(*position, press) > profile.multi_tap_slop
305                {
306                    self.strayed = true;
307                }
308                None
309            }
310            RawPointerEvent::Up {
311                position, button, ..
312            } => {
313                let (press, pressed_button) = self.press.take()?;
314                if self.strayed
315                    || pressed_button != *button
316                    || distance(*position, press) > profile.multi_tap_slop
317                {
318                    return None;
319                }
320                Some((press, *button))
321            }
322            RawPointerEvent::Cancel { .. } => {
323                self.press = None;
324                self.strayed = false;
325                None
326            }
327        }
328    }
329}
330
331/// How many simultaneous contacts a node handles.
332///
333/// The default, [`First`](Self::First), is what every widget written before the
334/// touch programme assumes: one press at a time. Under it a *second* contact
335/// arriving on the node is terminated there — neither delivered to the node nor
336/// bubbled to an ancestor — which is what stops two fingers landing on a button
337/// inside a scroll area from starting a pan with the second finger.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
339pub enum MultiContact {
340    /// Serve the first contact; refuse any other while it is live.
341    #[default]
342    First,
343    /// Serve every contact, each with its own live arena. What a multi-touch
344    /// surface (a pinch-zoom canvas, a piano keyboard) declares.
345    All,
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::gesture::test_helpers::*;
352
353    fn mouse() -> GestureProfile {
354        GestureProfile::MOUSE
355    }
356
357    #[test]
358    fn a_first_tap_starts_the_streak_at_one() {
359        let mut streak = TapStreak::EMPTY;
360        assert_eq!(streak.count(), 0);
361        let count = streak.advance(
362            EventTime::from_millis(10),
363            &mouse(),
364            Point::new(4.0, 4.0),
365            PointerButton::Primary,
366        );
367        assert_eq!(count, 1);
368        assert_eq!(streak.since_previous(), Duration::ZERO);
369    }
370
371    #[test]
372    fn a_second_tap_in_the_window_continues() {
373        let mut streak = TapStreak::EMPTY;
374        let p = Point::new(4.0, 4.0);
375        streak.advance(
376            EventTime::from_millis(10),
377            &mouse(),
378            p,
379            PointerButton::Primary,
380        );
381        let count = streak.advance(
382            EventTime::from_millis(200),
383            &mouse(),
384            p,
385            PointerButton::Primary,
386        );
387        assert_eq!(count, 2);
388        assert_eq!(streak.since_previous(), Duration::from_millis(190));
389    }
390
391    #[test]
392    fn a_different_button_restarts_the_streak() {
393        let mut streak = TapStreak::EMPTY;
394        let p = Point::new(4.0, 4.0);
395        streak.advance(
396            EventTime::from_millis(10),
397            &mouse(),
398            p,
399            PointerButton::Primary,
400        );
401        let count = streak.advance(
402            EventTime::from_millis(100),
403            &mouse(),
404            p,
405            PointerButton::Secondary,
406        );
407        assert_eq!(count, 1, "a mixed-button pair is never a double tap");
408    }
409
410    #[test]
411    fn exceeding_the_interval_restarts_the_streak() {
412        let mut streak = TapStreak::EMPTY;
413        let p = Point::new(4.0, 4.0);
414        streak.advance(
415            EventTime::from_millis(10),
416            &mouse(),
417            p,
418            PointerButton::Primary,
419        );
420        let count = streak.advance(
421            EventTime::from_millis(10 + 301),
422            &mouse(),
423            p,
424            PointerButton::Primary,
425        );
426        assert_eq!(count, 1);
427    }
428
429    #[test]
430    fn exceeding_the_multi_tap_slop_restarts_the_streak() {
431        let mut streak = TapStreak::EMPTY;
432        streak.advance(
433            EventTime::from_millis(10),
434            &mouse(),
435            Point::new(0.0, 0.0),
436            PointerButton::Primary,
437        );
438        let count = streak.advance(
439            EventTime::from_millis(100),
440            &mouse(),
441            Point::new(11.0, 0.0),
442            PointerButton::Primary,
443        );
444        assert_eq!(
445            count, 1,
446            "11 dp apart exceeds the mouse 10 dp multi-tap slop"
447        );
448    }
449
450    #[test]
451    fn a_reset_between_taps_restarts_the_streak() {
452        let mut streak = TapStreak::EMPTY;
453        let p = Point::new(4.0, 4.0);
454        streak.advance(
455            EventTime::from_millis(10),
456            &mouse(),
457            p,
458            PointerButton::Primary,
459        );
460        // Continuation rule 5: another gesture completed on the node.
461        streak.reset();
462        let count = streak.advance(
463            EventTime::from_millis(100),
464            &mouse(),
465            p,
466            PointerButton::Primary,
467        );
468        assert_eq!(count, 1);
469    }
470
471    #[test]
472    fn the_streak_restarts_after_three() {
473        let mut streak = TapStreak::EMPTY;
474        let p = Point::new(4.0, 4.0);
475        let mut counts = Vec::new();
476        for i in 0..4 {
477            counts.push(streak.advance(
478                EventTime::from_millis(10 + 100 * i),
479                &mouse(),
480                p,
481                PointerButton::Primary,
482            ));
483        }
484        assert_eq!(counts, vec![1, 2, 3, 1]);
485    }
486
487    #[test]
488    fn a_touch_profile_widens_the_window_the_streak_accepts() {
489        let mut streak = TapStreak::EMPTY;
490        streak.advance(
491            EventTime::from_millis(0),
492            &GestureProfile::TOUCH,
493            Point::new(0.0, 0.0),
494            PointerButton::Primary,
495        );
496        // 30 dp apart: over the mouse's 10 dp slop, inside touch's 40 dp.
497        let count = streak.advance(
498            EventTime::from_millis(100),
499            &GestureProfile::TOUCH,
500            Point::new(30.0, 0.0),
501            PointerButton::Primary,
502        );
503        assert_eq!(count, 2);
504    }
505
506    #[test]
507    fn a_release_that_strayed_does_not_qualify() {
508        let mut contact = TapContact::default();
509        let profile = mouse();
510        contact.observe(&down(Point::new(0.0, 0.0)), &profile);
511        contact.observe(&move_to(Point::new(40.0, 0.0)), &profile);
512        // Back where it started, but the excursion already disqualified it —
513        // exactly what the pre-P06 recognizers did by failing on the move.
514        assert!(
515            contact
516                .observe(&up(Point::new(0.0, 0.0)), &profile)
517                .is_none()
518        );
519    }
520
521    #[test]
522    fn a_release_within_slop_qualifies_and_reports_the_press_point() {
523        let mut contact = TapContact::default();
524        let profile = mouse();
525        contact.observe(&down(Point::new(1.0, 2.0)), &profile);
526        assert_eq!(
527            contact.observe(&up(Point::new(4.0, 2.0)), &profile),
528            Some((Point::new(1.0, 2.0), PointerButton::Primary))
529        );
530    }
531
532    #[test]
533    fn the_default_profile_follows_the_pointer_kind() {
534        assert_eq!(default_profile(PointerKind::Mouse), GestureProfile::MOUSE);
535        assert_eq!(default_profile(PointerKind::Touch), GestureProfile::TOUCH);
536    }
537}