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}