teksilo_core/gesture.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! UIKit-style gesture recognizer model.
5//!
6//! Gesture recognizers are composable state machines attached to widgets.
7//! Each recognizer monitors the raw pointer event stream and emits recognized
8//! gestures when patterns complete. They are pure state machines with no
9//! platform dependencies and **no wall clock** — every threshold and every
10//! instant reaches them through a [`RecognizerContext`], which is what makes
11//! them tunable per pointer kind and testable against a
12//! [`ManualClock`](crate::pointer::clock::ManualClock).
13//!
14//! The [`GestureArena`] arbitrates when multiple recognizers compete on the
15//! same event stream: all are fed in parallel, and when one recognizes, the
16//! rest are reset (except cooperative peers — see
17//! [`GestureRecognizer::resets_on_peer_recognition`]). One arena serves one
18//! *contact*; the [`GestureArenaSet`] a node carries owns one arena per live
19//! [`PointerId`](crate::pointer::PointerId) plus the node's [`TapStreak`],
20//! which outlives every contact so a touch double tap — two presses, two
21//! different pointer ids — can be recognized at all.
22//!
23//! **Click-style recognizers carry button + modifiers.** [`TapRecognizer`],
24//! [`DoubleTapRecognizer`], [`TripleTapRecognizer`], and
25//! [`LongPressRecognizer`] all default to `ButtonMask::PRIMARY` —
26//! left-click only — and emit [`TapEvent`]s carrying position, the
27//! finalising button, and modifier state. Multi-tap recognizers
28//! require button-match across the whole sequence. Widen the accepted
29//! set with `.accept_buttons(...)` / `.accept_any_button()`.
30
31use teksilo_canvas::{Point, Vec2};
32
33use crate::event::{Modifiers, PointerButton};
34use crate::pointer::{CancelReason, EventTime, PointerInfo};
35
36mod arena;
37mod arena_set;
38mod config;
39mod drag;
40mod long_press;
41mod multi_tap;
42mod palm;
43mod pan;
44mod pinch;
45mod sequence;
46mod swipe;
47mod tap;
48
49pub use arena::GestureArena;
50pub use arena_set::{GestureArenaSet, GestureProto};
51pub use config::{MultiContact, RecognizerContext, TapStreak, default_profile};
52pub use drag::DragRecognizer;
53pub use long_press::LongPressRecognizer;
54pub use multi_tap::{DoubleTapRecognizer, TripleTapRecognizer};
55pub use palm::{PALM_CONTACT_THRESHOLD, PalmWatch};
56pub use pan::PanRecognizer;
57pub use pinch::TouchPinchRecognizer;
58pub use sequence::{MemberRole, MemberState, PointerSequence, SequenceMember, TapBoundary};
59pub use swipe::SwipeRecognizer;
60pub use tap::TapRecognizer;
61
62/// Information about a recognized click-style gesture, passed to the
63/// four tap-family handlers (`on_tap`, `on_double_tap`, `on_triple_tap`,
64/// `on_long_press`).
65///
66/// The struct is `#[non_exhaustive]` so future fields (timestamp, click
67/// count for a hypothetical `on_n_tap`, pressure for stylus events) can
68/// land without breaking existing match patterns.
69#[derive(Debug, Clone, Copy)]
70#[non_exhaustive]
71pub struct TapEvent {
72 /// Pointer position in widget-local coords, captured at the
73 /// finalising event (the `Up` of the last tap for tap / double-tap /
74 /// triple-tap; the held `Down` for long-press, since long-press
75 /// recognises on a `tick` before any `Up`).
76 pub position: Point,
77
78 /// Which button finalised the gesture. Multi-tap recognizers
79 /// require every tap in the sequence to use the same button —
80 /// mixed-button sequences fail rather than spuriously firing.
81 pub button: PointerButton,
82
83 /// Modifier keys held at the finalising event. Sourced from
84 /// `WidgetEvent::PointerUp { modifiers, .. }` (or `PointerDown` for
85 /// long-press).
86 pub modifiers: Modifiers,
87
88 /// Which pointer produced the gesture — the mouse, a numbered finger, a
89 /// stylus. A handler reads `pointer.kind` to tell a finger tap from a
90 /// click without consulting the tree.
91 pub pointer: PointerInfo,
92}
93
94impl TapEvent {
95 /// Construct a `TapEvent` directly. Useful for tests; widgets receive
96 /// `&TapEvent` from the recognizer pipeline and rarely need to build
97 /// one by hand.
98 pub fn new(position: Point, button: PointerButton, modifiers: Modifiers) -> Self {
99 Self {
100 position,
101 button,
102 modifiers,
103 pointer: PointerInfo::mouse(EventTime::ZERO),
104 }
105 }
106
107 /// The same event attributed to `pointer`. The recognizers build their
108 /// `TapEvent`s this way, from the pointer on the sample that finalised the
109 /// gesture.
110 pub fn with_pointer(mut self, pointer: PointerInfo) -> Self {
111 self.pointer = pointer;
112 self
113 }
114}
115
116/// Raw pointer events fed into gesture recognizers.
117///
118/// Every variant carries the [`PointerInfo`] that produced it and the
119/// [`EventTime`] the backend stamped it with, so a recognizer never has to ask
120/// *which* contact this is or *when* it happened — the two questions a
121/// per-contact, clock-free recognizer cannot answer for itself.
122#[derive(Debug, Clone, Copy)]
123pub enum RawPointerEvent {
124 Down {
125 position: Point,
126 button: PointerButton,
127 modifiers: Modifiers,
128 pointer: PointerInfo,
129 time: EventTime,
130 },
131 Move {
132 position: Point,
133 pointer: PointerInfo,
134 time: EventTime,
135 },
136 Up {
137 position: Point,
138 button: PointerButton,
139 modifiers: Modifiers,
140 pointer: PointerInfo,
141 time: EventTime,
142 },
143 /// The interaction was revoked rather than completed — the window lost
144 /// focus, a modal opened over it, the OS took the pointer. Distinct from
145 /// `Up` on purpose: an `Up` means the user finished, a `Cancel` means the
146 /// system interrupted, and conflating them is how a drag ends up "dropped"
147 /// wherever the pointer happened to be.
148 Cancel {
149 position: Point,
150 pointer: PointerInfo,
151 reason: CancelReason,
152 time: EventTime,
153 },
154}
155
156impl RawPointerEvent {
157 /// Which pointer produced this event.
158 pub fn pointer(&self) -> PointerInfo {
159 match self {
160 Self::Down { pointer, .. }
161 | Self::Move { pointer, .. }
162 | Self::Up { pointer, .. }
163 | Self::Cancel { pointer, .. } => *pointer,
164 }
165 }
166
167 /// When it happened, on the tree's input timeline.
168 pub fn time(&self) -> EventTime {
169 match self {
170 Self::Down { time, .. }
171 | Self::Move { time, .. }
172 | Self::Up { time, .. }
173 | Self::Cancel { time, .. } => *time,
174 }
175 }
176
177 /// Where it happened, in the receiving widget's local coordinates.
178 pub fn position(&self) -> Point {
179 match self {
180 Self::Down { position, .. }
181 | Self::Move { position, .. }
182 | Self::Up { position, .. }
183 | Self::Cancel { position, .. } => *position,
184 }
185 }
186}
187
188/// Result of processing a raw event through a gesture recognizer.
189#[derive(Debug, Clone)]
190pub enum GestureResult {
191 /// Not enough data yet — keep feeding events.
192 Pending,
193 /// A gesture has been recognized.
194 Recognized(GestureEvent),
195 /// This event sequence cannot match the gesture — recognizer should be reset.
196 Failed,
197}
198
199/// A recognized gesture event.
200///
201/// The four click-style variants (`Tap` / `DoubleTap` / `TripleTap` /
202/// `LongPress`) carry a [`TapEvent`] payload — pointer position, the
203/// finalising mouse button, and the modifier state at that moment.
204#[derive(Debug, Clone, Copy)]
205pub enum GestureEvent {
206 Tap(TapEvent),
207 DoubleTap(TapEvent),
208 TripleTap(TapEvent),
209 LongPress(TapEvent),
210 DragStarted {
211 position: Point,
212 button: PointerButton,
213 pointer: PointerInfo,
214 },
215 DragMoved {
216 position: Point,
217 delta: Vec2,
218 pointer: PointerInfo,
219 },
220 DragEnded {
221 position: Point,
222 pointer: PointerInfo,
223 },
224 /// The drag was revoked rather than released. A handler that has been
225 /// mutating state since `DragStarted` must undo it here, not commit it.
226 DragCancelled {
227 position: Point,
228 pointer: PointerInfo,
229 reason: CancelReason,
230 },
231 PinchStarted {
232 center: Point,
233 },
234 /// A running pinch's geometry changed.
235 ///
236 /// # The producer contract
237 ///
238 /// Both `scale` and `rotation` are **per-sample deltas**, measured against
239 /// the previous sample of this same gesture — against the geometry at
240 /// [`PinchStarted`](Self::PinchStarted) for the first one. A consumer folds
241 /// each sample into what it already holds (multiplying for `scale`, adding
242 /// for `rotation`) and never reads a sample as an absolute.
243 ///
244 /// Deltas rather than values cumulative since the start, because a pinch has
245 /// two producers that must agree and only one of them *can* report a
246 /// cumulative value: winit's trackpad `PinchGesture` / `RotationGesture`
247 /// report a change per event and hand over no gesture-start baseline to
248 /// divide by. [`TouchPinchRecognizer`], which does have one, keeps it
249 /// internally and exposes it under a name that cannot be mistaken for these
250 /// fields — see
251 /// [`cumulative_scale`](TouchPinchRecognizer::cumulative_scale).
252 PinchChanged {
253 /// Midpoint of the two contacts (or of the trackpad gesture), in the
254 /// receiving widget's local coordinates.
255 center: Point,
256 /// The span **now** divided by the span at the previous sample. `1.0` is
257 /// no change, above `1.0` a spread, below `1.0` a squeeze. A producer
258 /// never emits `0.0`, a negative or a non-finite value; a consumer
259 /// handed one anyway should drop the sample rather than apply it.
260 /// Multiply by it — do not assign it.
261 scale: f32,
262 /// The twist since the previous sample, in **radians** (never degrees:
263 /// the platform translator converts at the seam, where winit's unit is
264 /// known). Signed, and unwrapped — a gesture turned past ±π keeps
265 /// producing same-signed steps instead of jumping by 2π. `0.0` from a
266 /// producer that reports magnification without rotation. Add it — do not
267 /// assign it.
268 rotation: f32,
269 },
270 PinchEnded,
271 /// The pinch was revoked rather than released. Emitted by
272 /// [`TouchPinchRecognizer`] when the cancel funnel takes one of its two
273 /// contacts away.
274 PinchCancelled {
275 reason: CancelReason,
276 },
277 Swipe {
278 direction: SwipeDirection,
279 velocity: f32,
280 },
281}
282
283/// Direction of a swipe gesture.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum SwipeDirection {
286 Left,
287 Right,
288 Up,
289 Down,
290}
291
292/// Phase of a drag gesture, as delivered to an `on_drag` handler.
293///
294/// This is the public API for drag handlers — the raw `GestureEvent::Drag*`
295/// variants are an implementation detail of the recognizer pipeline. A
296/// handler only ever receives `Started` once, followed by zero or more
297/// `Moved`, then exactly one `Ended`.
298///
299/// `#[non_exhaustive]`: [`Cancelled`](Self::Cancelled) joined the enum with the
300/// cancel funnel, and a phase carrying velocity is anticipated for the fling
301/// work, so a `match` on it needs a `_` arm.
302#[derive(Debug, Clone, Copy)]
303#[non_exhaustive]
304pub enum DragPhase {
305 Started {
306 position: Point,
307 button: PointerButton,
308 pointer: PointerInfo,
309 },
310 Moved {
311 position: Point,
312 delta: Vec2,
313 pointer: PointerInfo,
314 },
315 Ended {
316 position: Point,
317 pointer: PointerInfo,
318 },
319 /// The drag was revoked. Exactly one of `Ended` or `Cancelled` follows a
320 /// `Started`; a handler that committed nothing until `Ended` has nothing
321 /// to undo, and one that mutated as it went must roll back here.
322 Cancelled {
323 position: Point,
324 pointer: PointerInfo,
325 reason: CancelReason,
326 },
327}
328
329/// Phase of a pinch (or rotation) gesture, as delivered to an `on_pinch`
330/// handler. On desktop these are produced by OS trackpad gestures
331/// (`TouchpadMagnify` / `RotationGesture`); on touch they come from a
332/// dedicated recognizer ([`TouchPinchRecognizer`]). Both producers satisfy one
333/// contract, stated on [`Changed`](Self::Changed).
334///
335/// `#[non_exhaustive]` for the same reason as [`DragPhase`].
336#[derive(Debug, Clone, Copy)]
337#[non_exhaustive]
338pub enum PinchPhase {
339 Started {
340 center: Point,
341 pointer: PointerInfo,
342 },
343 /// The pinch's geometry changed.
344 ///
345 /// `scale` and `rotation` are **per-sample deltas** against the previous
346 /// sample of this gesture, exactly as
347 /// [`GestureEvent::PinchChanged`] defines them — fold each sample in
348 /// (multiply for `scale`, add for `rotation`) rather than assigning it.
349 Changed {
350 /// Midpoint of the gesture, in the receiving widget's local
351 /// coordinates.
352 center: Point,
353 /// The span now over the span at the previous sample. Multiply by it.
354 scale: f32,
355 /// The twist since the previous sample, in **radians**. Add it.
356 rotation: f32,
357 pointer: PointerInfo,
358 },
359 Ended {
360 pointer: PointerInfo,
361 },
362 /// The pinch was revoked rather than released.
363 Cancelled {
364 pointer: PointerInfo,
365 reason: CancelReason,
366 },
367}
368
369/// Trait for gesture recognizers. Each is a composable state machine.
370///
371/// Every method that could depend on the outside world takes a
372/// [`RecognizerContext`]: the current time, the [`GestureProfile`] for the
373/// pointer in play, the owning node's local bounds, the pointer itself, and
374/// the node's [`TapStreak`]. A recognizer therefore holds only the state of the
375/// *one contact* it is following — no clock, no thresholds of its own beyond
376/// explicit per-instance overrides, and no cross-contact tap counting.
377///
378/// [`GestureProfile`]: teksilo_tokens::GestureProfile
379pub trait GestureRecognizer {
380 /// Feed a raw pointer event and return the recognition result.
381 fn process(&mut self, event: &RawPointerEvent, cx: &RecognizerContext) -> GestureResult;
382
383 /// Advance any time-driven state (e.g. the long-press elapsed timer).
384 /// Default is a no-op — only recognizers that depend on time (like
385 /// [`LongPressRecognizer`]) override this.
386 fn tick(&mut self, _cx: &RecognizerContext) -> GestureResult {
387 GestureResult::Pending
388 }
389
390 /// Earliest future [`EventTime`] at which calling
391 /// [`tick`](GestureRecognizer::tick) could transition the recognizer into
392 /// `Recognized` or `Failed`. Returns `None` when the recognizer is idle or
393 /// not time-driven. Used by the event loop to schedule a wake-up before a
394 /// long press fires.
395 fn next_deadline(&self) -> Option<EventTime> {
396 None
397 }
398
399 /// Abandon the attempt in progress without emitting anything.
400 ///
401 /// Distinct from [`reset`](GestureRecognizer::reset) in intent rather than
402 /// in default behaviour: `reset` is arbitration bookkeeping ("you lost,
403 /// start over"), `cancel` is the user or the system taking the interaction
404 /// away. Defaults to `reset`; a recognizer whose mid-gesture state needs a
405 /// different unwind overrides it.
406 fn cancel(&mut self) {
407 self.reset();
408 }
409
410 /// Reset the recognizer to its initial state.
411 fn reset(&mut self);
412
413 /// Priority for arbitration when multiple recognizers compete.
414 /// Higher priority wins.
415 fn priority(&self) -> u32;
416
417 /// Whether this recognizer should be reset when a peer wins arbitration
418 /// in the same `GestureArena::process` call. The default is `true` —
419 /// winner-take-all, the usual behaviour for mutually exclusive gestures
420 /// (tap vs drag, long-press vs tap). Multi-tap recognizers
421 /// (`DoubleTapRecognizer`, `TripleTapRecognizer`) override this to
422 /// `false` so a `DoubleTap` firing at click 2 does not wipe the
423 /// `TripleTapRecognizer`'s accumulated state before click 3 arrives.
424 fn resets_on_peer_recognition(&self) -> bool {
425 true
426 }
427
428 /// Whether this recognizer belongs to the *tap family* — tap, double tap,
429 /// triple tap, long press.
430 ///
431 /// Read by [`GestureArenaSet::cancel_taps`], which revokes exactly this
432 /// family and leaves a live drag alone. That asymmetry is what WCAG 2.2
433 /// SC 2.5.2 ("Pointer Cancellation") needs: sliding off a control must
434 /// abort its activation, without aborting a drag the same press started.
435 fn tap_family(&self) -> bool {
436 false
437 }
438
439 /// Whether this recognizer takes part in cross-node sequence arbitration —
440 /// the "who owns this press" negotiation between a scrollable and the row
441 /// inside it. [`PanRecognizer`] is the one that says `true`.
442 ///
443 /// It is a **declaration, not a hook**: the router arbitrates on the
444 /// sequence's own [`MemberRole`], which it holds directly, so nothing on
445 /// the dispatch path has to interrogate a boxed recognizer to find out
446 /// what kind of competitor it is. The flag is what a reader — and a
447 /// third-party recognizer author — reads to know which side of that
448 /// negotiation a type belongs on.
449 fn competes_for_sequence(&self) -> bool {
450 false
451 }
452
453 /// Whether this recognizer wants every live contact rather than just the
454 /// one its arena was created for. [`TouchPinchRecognizer`] says `true`;
455 /// every single-contact recognizer says `false`.
456 ///
457 /// Also a declaration rather than a hook, and for a structural reason: a
458 /// [`GestureArena`] serves exactly one contact, so a recognizer that needs
459 /// two cannot live in one at all. The tree owns its pinch directly and
460 /// feeds it every contact (`widget_tree::pan_arbiter::feed_pinch`); the
461 /// flag is how such a type declares that it must be owned that way.
462 fn wants_all_pointers(&self) -> bool {
463 false
464 }
465}
466
467pub(crate) fn distance(a: Point, b: Point) -> f32 {
468 let dx = a.x - b.x;
469 let dy = a.y - b.y;
470 (dx * dx + dy * dy).sqrt()
471}
472
473#[cfg(test)]
474pub(crate) mod test_helpers {
475 use super::{EventTime, Modifiers, Point, PointerButton, PointerInfo, RawPointerEvent};
476 use crate::pointer::CancelReason;
477
478 /// The pointer every helper below attributes its event to unless told
479 /// otherwise: the mouse, at the epoch.
480 pub fn mouse_pointer() -> PointerInfo {
481 PointerInfo::mouse(EventTime::ZERO)
482 }
483
484 pub fn down(pos: Point) -> RawPointerEvent {
485 down_btn(pos, PointerButton::Primary)
486 }
487
488 pub fn down_btn(pos: Point, button: PointerButton) -> RawPointerEvent {
489 down_full(pos, button, Modifiers::NONE)
490 }
491
492 pub fn down_full(pos: Point, button: PointerButton, modifiers: Modifiers) -> RawPointerEvent {
493 RawPointerEvent::Down {
494 position: pos,
495 button,
496 modifiers,
497 pointer: mouse_pointer(),
498 time: EventTime::ZERO,
499 }
500 }
501
502 pub fn up(pos: Point) -> RawPointerEvent {
503 up_btn(pos, PointerButton::Primary)
504 }
505
506 pub fn up_btn(pos: Point, button: PointerButton) -> RawPointerEvent {
507 up_full(pos, button, Modifiers::NONE)
508 }
509
510 pub fn up_full(pos: Point, button: PointerButton, modifiers: Modifiers) -> RawPointerEvent {
511 RawPointerEvent::Up {
512 position: pos,
513 button,
514 modifiers,
515 pointer: mouse_pointer(),
516 time: EventTime::ZERO,
517 }
518 }
519
520 pub fn move_to(pos: Point) -> RawPointerEvent {
521 RawPointerEvent::Move {
522 position: pos,
523 pointer: mouse_pointer(),
524 time: EventTime::ZERO,
525 }
526 }
527
528 pub fn cancel_at(pos: Point) -> RawPointerEvent {
529 RawPointerEvent::Cancel {
530 position: pos,
531 pointer: mouse_pointer(),
532 reason: CancelReason::Platform,
533 time: EventTime::ZERO,
534 }
535 }
536
537 /// The same event attributed to `pointer` and stamped at `time`.
538 pub fn retimed(
539 event: RawPointerEvent,
540 pointer: PointerInfo,
541 time: EventTime,
542 ) -> RawPointerEvent {
543 match event {
544 RawPointerEvent::Down {
545 position,
546 button,
547 modifiers,
548 ..
549 } => RawPointerEvent::Down {
550 position,
551 button,
552 modifiers,
553 pointer,
554 time,
555 },
556 RawPointerEvent::Move { position, .. } => RawPointerEvent::Move {
557 position,
558 pointer,
559 time,
560 },
561 RawPointerEvent::Up {
562 position,
563 button,
564 modifiers,
565 ..
566 } => RawPointerEvent::Up {
567 position,
568 button,
569 modifiers,
570 pointer,
571 time,
572 },
573 RawPointerEvent::Cancel {
574 position, reason, ..
575 } => RawPointerEvent::Cancel {
576 position,
577 pointer,
578 reason,
579 time,
580 },
581 }
582 }
583}
584
585#[cfg(test)]
586mod source_scan_tests {
587 /// Every `.rs` file the gesture layer is made of.
588 fn gesture_sources() -> Vec<(String, String)> {
589 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/gesture");
590 let mut out = vec![(
591 "gesture.rs".to_string(),
592 std::fs::read_to_string(
593 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/gesture.rs"),
594 )
595 .expect("gesture.rs is readable"),
596 )];
597 for entry in std::fs::read_dir(&root).expect("src/gesture is readable") {
598 let path = entry.expect("readable dir entry").path();
599 if path.extension().and_then(|e| e.to_str()) == Some("rs") {
600 let name = path
601 .file_name()
602 .and_then(|n| n.to_str())
603 .unwrap_or("?")
604 .to_string();
605 out.push((name, std::fs::read_to_string(&path).expect("readable")));
606 }
607 }
608 out
609 }
610
611 /// Everything outside a `#[cfg(test)] mod` / `#[cfg(test)] impl` block:
612 /// the code that actually ships.
613 ///
614 /// Byte-indexed throughout — these files are full of em-dashes, and the
615 /// only positions it ever cuts at (`#[cfg(test)]`, `{`, `}`) are ASCII, so
616 /// every slice lands on a character boundary.
617 fn production_only(source: &str) -> String {
618 const MARKER: &[u8] = b"#[cfg(test)]";
619 let bytes = source.as_bytes();
620 let mut out = String::with_capacity(source.len());
621 let mut kept_from = 0usize;
622 let mut i = 0usize;
623 while i < bytes.len() {
624 if !bytes[i..].starts_with(MARKER) {
625 i += 1;
626 continue;
627 }
628 // Only `mod` and `impl` introduce a whole block of test-only code.
629 // A `#[cfg(test)]` on a field or a single fn is left in place — and
630 // must therefore still be free of wall-clock reads to matter.
631 let after = i + MARKER.len();
632 let head_end = bytes[after..]
633 .iter()
634 .position(|c| *c == b'{')
635 .map(|off| after + off);
636 let Some(head_end) = head_end else { break };
637 let head = source[after..head_end].trim_start();
638 if !(head.starts_with("mod ") || head.starts_with("impl ")) {
639 i = after;
640 continue;
641 }
642 // Brace-match from the block's opening brace.
643 let mut depth = 0usize;
644 let mut j = head_end;
645 while j < bytes.len() {
646 match bytes[j] {
647 b'{' => depth += 1,
648 b'}' => {
649 depth -= 1;
650 if depth == 0 {
651 j += 1;
652 break;
653 }
654 }
655 _ => {}
656 }
657 j += 1;
658 }
659 out.push_str(&source[kept_from..i]);
660 kept_from = j;
661 i = j;
662 }
663 out.push_str(&source[kept_from..]);
664 out
665 }
666
667 /// A recognizer that reads the wall clock cannot be driven by a simulated
668 /// one, and a test that cannot advance the clock cannot test a long press
669 /// or a double-tap window without sleeping. `Instant::now()` is therefore
670 /// banned from the gesture layer outside its own test blocks — time
671 /// arrives through `RecognizerContext::now`.
672 /// Everything before the first `//` on each line: code, not prose. Keeps
673 /// the scan from tripping over its own documentation, which names
674 /// `Instant::now()` to explain why it is banned.
675 fn code_only(source: &str) -> String {
676 source
677 .lines()
678 .map(|line| match line.find("//") {
679 Some(idx) => &line[..idx],
680 None => line,
681 })
682 .collect::<Vec<_>>()
683 .join("\n")
684 }
685
686 #[test]
687 fn no_wall_clock_in_gestures() {
688 for (name, source) in gesture_sources() {
689 let production = code_only(&production_only(&source));
690 assert!(
691 !production.contains("Instant::now()"),
692 "{name} reads the wall clock outside its test blocks; gesture \
693 recognizers must take their time from `RecognizerContext::now`"
694 );
695 }
696 }
697
698 /// The stripper must not eat the whole file — otherwise the scan above
699 /// passes vacuously.
700 #[test]
701 fn the_scan_keeps_the_production_half_of_each_file() {
702 for (name, source) in gesture_sources() {
703 let production = production_only(&source);
704 assert!(
705 production.contains("// SPDX-License-Identifier"),
706 "{name}: the test-block stripper ate the file header"
707 );
708 assert!(
709 code_only(&production).contains("use "),
710 "{name}: the comment stripper ate the code"
711 );
712 if source.contains("pub struct") {
713 assert!(
714 production.contains("pub struct"),
715 "{name}: the test-block stripper ate the production types"
716 );
717 }
718 }
719 }
720
721 /// The scan is only meaningful if it actually found the files.
722 #[test]
723 fn the_source_scan_sees_every_recognizer() {
724 let names: Vec<String> = gesture_sources().into_iter().map(|(n, _)| n).collect();
725 for expected in [
726 "gesture.rs",
727 "arena.rs",
728 "arena_set.rs",
729 "config.rs",
730 "drag.rs",
731 "long_press.rs",
732 "multi_tap.rs",
733 "palm.rs",
734 "pan.rs",
735 "pinch.rs",
736 "swipe.rs",
737 "tap.rs",
738 ] {
739 assert!(
740 names.iter().any(|n| n == expected),
741 "the wall-clock scan missed {expected}; it saw {names:?}"
742 );
743 }
744 }
745}