rlvgl_platform/gesture.rs
1//! Gesture recognition layer.
2//!
3//! Sits between raw hardware input (PointerDown/PointerUp/PointerMove) and
4//! the widget tree, converting noisy touch events into clean gesture events
5//! (PressDown/PressRelease/DoubleTap).
6//!
7//! The [`crate::gesture::TapRecognizer`] debounces capacitive touch bounce by
8//! requiring the contact to settle before emitting a single `PressRelease`.
9//! Visual press feedback (`PressDown`) is emitted immediately on first contact.
10//!
11//! The [`crate::gesture::DoubleTapRecognizer`] sits downstream of
12//! [`crate::gesture::TapRecognizer`] and detects two consecutive short taps,
13//! emitting `DoubleTap` instead of the second `PressRelease`.
14//!
15//! The [`crate::gesture::DragRecognizer`] sits **upstream** of
16//! [`crate::gesture::TapRecognizer`] (canonical chain: raw → Drag → Tap →
17//! DoubleTap, INPUT-00 §6.1), converting pointer movement past a start
18//! threshold into `DragStart`/`DragMove`/`DragEnd` and consuming the raw
19//! move/up stream while dragging so the tap chain never settles into a
20//! `PressRelease` — pipelines MUST also call [`TapRecognizer::cancel`]
21//! when a `DragStart` crosses the chain (click-vs-drag suppression).
22
23use rlvgl_core::event::Event;
24
25// ── Duration constants (tunable, frame-rate independent) ───────────────────
26
27/// Debounce settle period for `TapRecognizer` — long enough to absorb
28/// FT5336 capacitive touch bounce.
29pub const SETTLE_MS: u32 = 200;
30
31/// Maximum hold duration (PressDown → PressRelease) for a tap to count as
32/// "short". Taps held longer than this are treated as long-presses and will
33/// not arm the double-tap detector.
34pub const SHORT_PRESS_MAX_MS: u32 = 250;
35
36/// Maximum gap between two consecutive short taps for the pair to be
37/// recognized as a double-tap. Starts counting from the first PressRelease.
38pub const DOUBLE_TAP_WINDOW_MS: u32 = 400;
39
40/// Maximum spatial distance (Manhattan) between two taps for them to count
41/// as a double-tap. Prevents accidental double-taps on different list rows.
42pub const DOUBLE_TAP_MAX_DISTANCE: i32 = 20;
43
44/// Default drag start threshold: minimum Euclidean displacement (in pixels)
45/// from the press origin before a contact becomes a drag. Compared squared
46/// (`dx² + dy² ≥ t²`) — no sqrt, isotropic (INPUT-00 §5.1; deliberately not
47/// Manhattan like [`DOUBLE_TAP_MAX_DISTANCE`], which gates proximity, not
48/// motion).
49pub const DRAG_START_THRESHOLD_PX: i32 = 10;
50
51/// Convert a duration in milliseconds to tick counts at the given frame rate,
52/// rounding up so we never undercount.
53fn ms_to_ticks(ms: u32, frame_hz: u32) -> u8 {
54 (ms * frame_hz).div_ceil(1000) as u8
55}
56
57// ── TapRecognizer ──────────────────────────────────────────────────────────
58
59/// Tap gesture recognizer with duration-based settle period.
60///
61/// Converts raw `PointerDown`/`PointerUp` into debounced `PressDown`/`PressRelease`.
62/// Feed raw events via [`process`](Self::process) and call [`tick`](Self::tick)
63/// each frame to advance the settle timer.
64pub struct TapRecognizer {
65 state: TapState,
66 /// Position of the current/pending contact.
67 pos: (i32, i32),
68 /// Ticks remaining before firing PressRelease.
69 settle: u8,
70 /// Settle duration in ticks (derived from [`SETTLE_MS`] and frame rate).
71 max_settle: u8,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum TapState {
76 /// No active contact.
77 Idle,
78 /// Contact is active (PressDown already emitted).
79 Down,
80 /// PointerUp received, waiting for settle before emitting PressRelease.
81 PendingRelease,
82}
83
84impl TapRecognizer {
85 /// Create a new recognizer. Settle duration is derived from [`SETTLE_MS`]
86 /// at the given frame rate.
87 pub fn new(frame_hz: u32) -> Self {
88 Self {
89 state: TapState::Idle,
90 pos: (0, 0),
91 settle: 0,
92 max_settle: ms_to_ticks(SETTLE_MS, frame_hz),
93 }
94 }
95
96 /// Process a raw input event. Returns a gesture event to dispatch,
97 /// or `None` if the event was consumed internally.
98 ///
99 /// Only `PointerDown`, `PointerUp`, and `PointerMove` are processed.
100 /// All other events pass through unchanged.
101 pub fn process(&mut self, event: &Event) -> Option<Event> {
102 match event {
103 Event::PointerDown { x, y } => {
104 self.pos = (*x, *y);
105 match self.state {
106 TapState::Idle => {
107 // Fresh contact — emit PressDown for visual feedback
108 self.state = TapState::Down;
109 Some(Event::PressDown { x: *x, y: *y })
110 }
111 TapState::Down => {
112 // Already down — update position (drag start)
113 None
114 }
115 TapState::PendingRelease => {
116 // Bounce! New PointerDown during settle — go back to Down
117 self.state = TapState::Down;
118 self.settle = 0;
119 None // don't re-emit PressDown, it was already sent
120 }
121 }
122 }
123 Event::PointerUp { x, y } => {
124 self.pos = (*x, *y);
125 match self.state {
126 TapState::Down => {
127 // Start settle timer — don't emit PressRelease yet
128 self.state = TapState::PendingRelease;
129 self.settle = self.max_settle;
130 None
131 }
132 TapState::PendingRelease => {
133 // Another PointerUp during settle — update position, reset timer
134 self.settle = self.max_settle;
135 None
136 }
137 TapState::Idle => {
138 // Spurious PointerUp with no prior Down — ignore
139 None
140 }
141 }
142 }
143 Event::PointerMove { x, y } => {
144 if self.state == TapState::Down {
145 self.pos = (*x, *y);
146 }
147 // Pass through as-is for widgets that want move tracking
148 Some(event.clone())
149 }
150 // All other events pass through unchanged
151 _ => Some(event.clone()),
152 }
153 }
154
155 /// Advance the settle timer. Call once per Tick.
156 ///
157 /// Returns `PressRelease` when the settle period expires, or `None`.
158 pub fn tick(&mut self) -> Option<Event> {
159 if self.state == TapState::PendingRelease {
160 if self.settle > 0 {
161 self.settle -= 1;
162 }
163 if self.settle == 0 {
164 self.state = TapState::Idle;
165 let (x, y) = self.pos;
166 return Some(Event::PressRelease { x, y });
167 }
168 }
169 None
170 }
171
172 /// Abandon the active contact: reset to `Idle`, dropping any pending
173 /// settle so no `PressRelease` is emitted for it.
174 ///
175 /// Pipelines MUST call this when a [`DragRecognizer`] upstream emits
176 /// `DragStart` (INPUT-00 §6.2) — the drag consumes the contact's
177 /// remaining `PointerMove`/`PointerUp` stream, so without the cancel
178 /// this recognizer would be stranded in its down state and corrupt the
179 /// next tap.
180 pub fn cancel(&mut self) {
181 self.state = TapState::Idle;
182 self.settle = 0;
183 }
184}
185
186// ── DragRecognizer ─────────────────────────────────────────────────────────
187
188/// Drag gesture recognizer (INPUT-00 §5).
189///
190/// Sits **upstream** of [`TapRecognizer`], consuming raw
191/// `PointerDown`/`PointerMove`/`PointerUp`. A contact that moves at least
192/// the start threshold (Euclidean, compared squared) away from its press
193/// origin becomes a drag: `DragStart { x, y, origin_x, origin_y }` is
194/// emitted once at the crossing, `DragMove { x, y }` for every subsequent
195/// move, and `DragEnd { x, y }` in place of the final `PointerUp`. While
196/// dragging, the raw move/up stream is consumed — combined with
197/// [`TapRecognizer::cancel`] at `DragStart`, a threshold-crossing contact
198/// can never also produce a `PressRelease`.
199///
200/// Sub-threshold contacts pass through untouched: taps with finger wander
201/// keep their existing debounce path. Non-pointer events always pass
202/// through unchanged.
203pub struct DragRecognizer {
204 state: DragState,
205 /// Press origin the displacement is measured from.
206 origin: (i32, i32),
207 /// Squared start threshold (`i64`: comfortably holds the worst-case
208 /// `dx² + dy²` of any plausible screen).
209 threshold_sq: i64,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213enum DragState {
214 /// No active contact.
215 Idle,
216 /// Contact active, displacement still below the threshold.
217 Armed,
218 /// Threshold crossed; `DragStart` already emitted.
219 Dragging,
220}
221
222impl DragRecognizer {
223 /// Create a recognizer with the default
224 /// [`DRAG_START_THRESHOLD_PX`] start threshold.
225 pub fn new() -> Self {
226 Self::with_threshold(DRAG_START_THRESHOLD_PX)
227 }
228
229 /// Create a recognizer with a custom start threshold in pixels.
230 /// A threshold of `0` makes the first `PointerMove` start the drag.
231 pub fn with_threshold(threshold_px: i32) -> Self {
232 let t = threshold_px.max(0) as i64;
233 Self {
234 state: DragState::Idle,
235 origin: (0, 0),
236 threshold_sq: t * t,
237 }
238 }
239
240 /// Whether a drag is currently in progress (`DragStart` emitted, no
241 /// `DragEnd` yet).
242 pub fn is_dragging(&self) -> bool {
243 self.state == DragState::Dragging
244 }
245
246 /// Process a raw input event. Returns the event to pass down the
247 /// chain — either a drag event (the raw event was consumed) or the
248 /// input itself (pass-through), `None` never escapes a pointer event
249 /// silently.
250 ///
251 /// Only `PointerDown`, `PointerMove`, and `PointerUp` participate;
252 /// everything else passes through unchanged.
253 pub fn process(&mut self, event: &Event) -> Option<Event> {
254 match event {
255 Event::PointerDown { x, y } => {
256 // Fresh contact (or lost-up glitch while dragging —
257 // re-arm defensively, INPUT-00 §5.5).
258 self.state = DragState::Armed;
259 self.origin = (*x, *y);
260 Some(event.clone())
261 }
262 Event::PointerMove { x, y } => match self.state {
263 DragState::Armed => {
264 let dx = (*x - self.origin.0) as i64;
265 let dy = (*y - self.origin.1) as i64;
266 if dx * dx + dy * dy >= self.threshold_sq {
267 self.state = DragState::Dragging;
268 Some(Event::DragStart {
269 x: *x,
270 y: *y,
271 origin_x: self.origin.0,
272 origin_y: self.origin.1,
273 })
274 } else {
275 Some(event.clone())
276 }
277 }
278 DragState::Dragging => Some(Event::DragMove { x: *x, y: *y }),
279 DragState::Idle => Some(event.clone()),
280 },
281 Event::PointerUp { x, y } => match self.state {
282 DragState::Dragging => {
283 self.state = DragState::Idle;
284 Some(Event::DragEnd { x: *x, y: *y })
285 }
286 _ => {
287 self.state = DragState::Idle;
288 Some(event.clone())
289 }
290 },
291 // All other events pass through unchanged.
292 _ => Some(event.clone()),
293 }
294 }
295
296 /// Family-shape parity with the other recognizers. The drag state
297 /// machine has no timers in v1; this always returns `None` and
298 /// reserves the seam for long-press-to-drag (INPUT-00 §14).
299 pub fn tick(&mut self) -> Option<Event> {
300 None
301 }
302}
303
304impl Default for DragRecognizer {
305 fn default() -> Self {
306 Self::new()
307 }
308}
309
310// ── DoubleTapRecognizer ────────────────────────────────────────────────────
311
312/// Double-tap gesture recognizer.
313///
314/// Sits downstream of [`TapRecognizer`], consuming `PressDown`/`PressRelease`
315/// events and emitting `DoubleTap` when two consecutive short taps are
316/// detected within the time and distance window.
317///
318/// The first `PressRelease` is buffered — it is only forwarded after the
319/// double-tap window expires without a second tap. This adds up to
320/// [`DOUBLE_TAP_WINDOW_MS`] of latency to single taps.
321pub struct DoubleTapRecognizer {
322 state: DtState,
323 /// Position of the buffered first tap.
324 armed_pos: (i32, i32),
325 /// Ticks remaining in the double-tap window.
326 countdown: u8,
327 /// Tick count when the most recent PressDown arrived (for hold measurement).
328 down_tick: u8,
329 /// Running tick counter, wraps at u8::MAX.
330 tick_counter: u8,
331 // Derived thresholds
332 short_press_max_ticks: u8,
333 window_ticks: u8,
334 max_distance: i32,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338enum DtState {
339 /// Waiting for a short tap.
340 Idle,
341 /// First short tap received and buffered. Waiting for second tap or timeout.
342 Armed,
343}
344
345impl DoubleTapRecognizer {
346 /// Create a new recognizer with thresholds derived from the duration
347 /// constants at the given frame rate.
348 pub fn new(frame_hz: u32) -> Self {
349 Self {
350 state: DtState::Idle,
351 armed_pos: (0, 0),
352 countdown: 0,
353 down_tick: 0,
354 tick_counter: 0,
355 short_press_max_ticks: ms_to_ticks(SHORT_PRESS_MAX_MS, frame_hz),
356 window_ticks: ms_to_ticks(DOUBLE_TAP_WINDOW_MS, frame_hz),
357 max_distance: DOUBLE_TAP_MAX_DISTANCE,
358 }
359 }
360
361 /// Process a gesture event (output of [`TapRecognizer`]).
362 ///
363 /// Returns up to two events to dispatch. The second slot is used when an
364 /// out-of-range second tap arrives: the buffered first `PressRelease` is
365 /// emitted, and the recognizer re-arms with the new position.
366 pub fn process(&mut self, event: &Event) -> (Option<Event>, Option<Event>) {
367 match event {
368 Event::PressDown { .. } => {
369 // Record when the finger went down for hold-duration measurement
370 self.down_tick = self.tick_counter;
371 (Some(event.clone()), None)
372 }
373 Event::PressRelease { x, y } => {
374 let hold = self.tick_counter.wrapping_sub(self.down_tick);
375 let is_short = hold <= self.short_press_max_ticks;
376
377 match self.state {
378 DtState::Idle => {
379 if is_short {
380 // Buffer this tap and arm the double-tap detector
381 self.state = DtState::Armed;
382 self.armed_pos = (*x, *y);
383 self.countdown = self.window_ticks;
384 (None, None) // suppress — will emit on timeout or double
385 } else {
386 // Long press — pass through immediately
387 (Some(event.clone()), None)
388 }
389 }
390 DtState::Armed => {
391 let (ax, ay) = self.armed_pos;
392 let dist = (ax - *x).abs() + (ay - *y).abs();
393
394 if is_short && dist <= self.max_distance {
395 // Double tap! Emit DoubleTap, suppress both PressRelease.
396 self.state = DtState::Idle;
397 self.countdown = 0;
398 (Some(Event::DoubleTap { x: *x, y: *y }), None)
399 } else {
400 // Too far or too slow — emit the buffered first tap
401 let first = Event::PressRelease { x: ax, y: ay };
402 if is_short {
403 // Re-arm with the new position
404 self.armed_pos = (*x, *y);
405 self.countdown = self.window_ticks;
406 (Some(first), None)
407 } else {
408 // Long press — emit both and go idle
409 self.state = DtState::Idle;
410 self.countdown = 0;
411 (Some(first), Some(event.clone()))
412 }
413 }
414 }
415 }
416 }
417 // All other events pass through
418 _ => (Some(event.clone()), None),
419 }
420 }
421
422 /// Advance the double-tap window timer. Call once per Tick.
423 ///
424 /// Returns the buffered `PressRelease` when the window expires without a
425 /// second tap.
426 pub fn tick(&mut self) -> Option<Event> {
427 self.tick_counter = self.tick_counter.wrapping_add(1);
428
429 if self.state == DtState::Armed {
430 if self.countdown > 0 {
431 self.countdown -= 1;
432 }
433 if self.countdown == 0 {
434 self.state = DtState::Idle;
435 let (x, y) = self.armed_pos;
436 return Some(Event::PressRelease { x, y });
437 }
438 }
439 None
440 }
441}
442
443// ── LongPressRecognizer ────────────────────────────────────────────────────
444
445/// Nominal long-press threshold aligned with LVGL's default (400 ms).
446///
447/// At 30 Hz: 12 ticks ≈ 400 ms.
448/// At 60 Hz: 24 ticks ≈ 400 ms.
449///
450/// This is the *default* value; pass a custom `long_press_ticks` to
451/// [`LongPressConfig`] to override per instance.
452pub const LONG_PRESS_TICKS: u32 = 12;
453
454/// Nominal long-press repeat interval aligned with LVGL's default (100 ms).
455///
456/// At 30 Hz: 3 ticks ≈ 100 ms.
457/// At 60 Hz: 6 ticks ≈ 100 ms.
458///
459/// This is the *default* value; pass a custom `repeat_ticks` to
460/// [`LongPressConfig`] to override per instance.
461pub const LONG_PRESS_REPEAT_TICKS: u32 = 3;
462
463/// Configuration for a [`LongPressRecognizer`] instance.
464///
465/// All durations are in Tick counts (LPAR-04 §9.1 — no wall-clock APIs).
466/// Convert milliseconds to ticks at your loop edge if needed.
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub struct LongPressConfig {
469 /// Number of ticks a contact must be held before `LongPress` is emitted.
470 /// Defaults to [`LONG_PRESS_TICKS`] (≈ 400 ms at 30 Hz).
471 pub long_press_ticks: u32,
472 /// Number of ticks between successive `LongPressRepeat` emissions after the
473 /// initial `LongPress`. Defaults to [`LONG_PRESS_REPEAT_TICKS`] (≈ 100 ms
474 /// at 30 Hz).
475 pub repeat_ticks: u32,
476}
477
478impl Default for LongPressConfig {
479 fn default() -> Self {
480 Self {
481 long_press_ticks: LONG_PRESS_TICKS,
482 repeat_ticks: LONG_PRESS_REPEAT_TICKS,
483 }
484 }
485}
486
487/// Long-press and repeat gesture recognizer (LPAR-04 §9).
488///
489/// Sits **between** [`DragRecognizer`] and [`TapRecognizer`] in the canonical
490/// chain (raw → Drag → **LongPress** → Tap → DoubleTap, LPAR-04 §8.3).
491///
492/// While a contact is held — armed on `PressDown` and updated by
493/// `PointerMove`/`DragMove` — each call to [`tick`](Self::tick) increments an
494/// internal counter. Once the counter reaches `long_press_ticks` the recognizer
495/// emits [`Event::LongPress`] exactly once. Every `repeat_ticks` thereafter it
496/// emits [`Event::LongPressRepeat`]. A [`DragStart`](Event::DragStart),
497/// [`PressRelease`](Event::PressRelease), [`PointerUp`](Event::PointerUp), or
498/// [`DragEnd`](Event::DragEnd) disarms the recognizer without emitting.
499///
500/// All events pass through unchanged — this recognizer is purely additive and
501/// does **not** suppress or rewrite any existing stream event (LPAR-04 §9.5).
502///
503/// # Determinism
504///
505/// Identical sequences of `process` and `tick` calls produce identical output
506/// sequences. There is no wall-clock dependency (LPAR-04 §9.1/§9.2).
507pub struct LongPressRecognizer {
508 state: LpState,
509 /// Last known contact position (updated by `PressDown`/`PointerMove`/
510 /// `DragMove` while armed).
511 pos: (i32, i32),
512 /// Tick counter since arming. Compared against `config.long_press_ticks`
513 /// and used to schedule repeats.
514 counter: u32,
515 /// Active configuration.
516 config: LongPressConfig,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520enum LpState {
521 /// No active contact.
522 Idle,
523 /// Contact held; long press not yet fired.
524 Armed,
525 /// `LongPress` already emitted; tracking for `LongPressRepeat`.
526 Fired,
527}
528
529impl LongPressRecognizer {
530 /// Create a recognizer with default thresholds ([`LONG_PRESS_TICKS`] and
531 /// [`LONG_PRESS_REPEAT_TICKS`]).
532 pub fn new() -> Self {
533 Self::with_config(LongPressConfig::default())
534 }
535
536 /// Create a recognizer with custom thresholds.
537 ///
538 /// `config.repeat_ticks` MUST be at least 1; a value of 0 is clamped to 1
539 /// to prevent infinite repeat storms.
540 pub fn with_config(config: LongPressConfig) -> Self {
541 let config = LongPressConfig {
542 repeat_ticks: config.repeat_ticks.max(1),
543 ..config
544 };
545 Self {
546 state: LpState::Idle,
547 pos: (0, 0),
548 counter: 0,
549 config,
550 }
551 }
552
553 /// Process a stream event. All events pass through unchanged.
554 ///
555 /// The recognizer tracks contact state to arm/disarm the long-press timer:
556 /// - `PressDown` — arms the recognizer and records the press position.
557 /// - `PointerMove` / `DragMove` — updates the tracked position while armed
558 /// or fired (so the `LongPress`/`LongPressRepeat` coordinates reflect the
559 /// most recent contact position).
560 /// - `DragStart` — disarms (LPAR-04 §9.4).
561 /// - `PointerUp` / `PressRelease` / `DragEnd` — disarms.
562 ///
563 /// No event is ever `None`-d by this recognizer; it only *adds* output via
564 /// [`tick`](Self::tick).
565 pub fn process(&mut self, event: &Event) -> Option<Event> {
566 match event {
567 Event::PressDown { x, y } => {
568 self.state = LpState::Armed;
569 self.pos = (*x, *y);
570 self.counter = 0;
571 }
572 Event::PointerMove { x, y } | Event::DragMove { x, y }
573 if self.state != LpState::Idle =>
574 {
575 self.pos = (*x, *y);
576 }
577 Event::DragStart { .. }
578 | Event::PointerUp { .. }
579 | Event::PressRelease { .. }
580 | Event::DragEnd { .. } => {
581 self.cancel();
582 }
583 _ => {}
584 }
585 Some(event.clone())
586 }
587
588 /// Advance the long-press timer. Call once per [`Event::Tick`].
589 ///
590 /// Returns:
591 /// - `Some(Event::LongPress { x, y })` on the tick that crosses
592 /// `long_press_ticks` (emitted exactly once per contact).
593 /// - `Some(Event::LongPressRepeat { x, y })` on every `repeat_ticks`
594 /// tick after the initial long press.
595 /// - `None` otherwise.
596 pub fn tick(&mut self) -> Option<Event> {
597 match self.state {
598 LpState::Idle => None,
599 LpState::Armed => {
600 self.counter += 1;
601 if self.counter >= self.config.long_press_ticks {
602 self.state = LpState::Fired;
603 // Reset counter so the first repeat fires after `repeat_ticks`
604 // more ticks, not immediately.
605 self.counter = 0;
606 let (x, y) = self.pos;
607 Some(Event::LongPress { x, y })
608 } else {
609 None
610 }
611 }
612 LpState::Fired => {
613 self.counter += 1;
614 if self.counter >= self.config.repeat_ticks {
615 self.counter = 0;
616 let (x, y) = self.pos;
617 Some(Event::LongPressRepeat { x, y })
618 } else {
619 None
620 }
621 }
622 }
623 }
624
625 /// Disarm the recognizer, dropping any pending long-press.
626 ///
627 /// Pipelines MUST call this when [`DragRecognizer`] upstream emits
628 /// `DragStart` (LPAR-04 §9.4) — the same chain-cancellation contract as
629 /// [`TapRecognizer::cancel`] (INPUT-00 §6.2).
630 pub fn cancel(&mut self) {
631 self.state = LpState::Idle;
632 self.counter = 0;
633 }
634}
635
636impl Default for LongPressRecognizer {
637 fn default() -> Self {
638 Self::new()
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use alloc::vec;
646 use alloc::vec::Vec;
647
648 // ── TapRecognizer tests ────────────────────────────────────────────
649
650 #[test]
651 fn tap_produces_press_down_then_release() {
652 let mut tap = TapRecognizer::new(30);
653
654 // PointerDown → PressDown immediately
655 let result = tap.process(&Event::PointerDown { x: 100, y: 200 });
656 assert_eq!(result, Some(Event::PressDown { x: 100, y: 200 }));
657
658 // PointerUp → queued, no output yet
659 let result = tap.process(&Event::PointerUp { x: 100, y: 200 });
660 assert_eq!(result, None);
661
662 // Tick through settle period (SETTLE_MS=200 at 30Hz = 6 ticks)
663 for _ in 0..5 {
664 assert_eq!(tap.tick(), None);
665 }
666 let result = tap.tick();
667 assert_eq!(result, Some(Event::PressRelease { x: 100, y: 200 }));
668
669 // Subsequent ticks — idle
670 assert_eq!(tap.tick(), None);
671 }
672
673 #[test]
674 fn bounce_suppressed() {
675 let mut tap = TapRecognizer::new(30);
676
677 tap.process(&Event::PointerDown { x: 10, y: 20 });
678 tap.process(&Event::PointerUp { x: 10, y: 20 });
679
680 // Bounce: new PointerDown before settle
681 let result = tap.process(&Event::PointerDown { x: 10, y: 20 });
682 assert_eq!(result, None);
683
684 tap.process(&Event::PointerUp { x: 10, y: 20 });
685
686 // Settle
687 let settle_ticks = ms_to_ticks(SETTLE_MS, 30);
688 for _ in 0..(settle_ticks - 1) {
689 assert_eq!(tap.tick(), None);
690 }
691 assert_eq!(tap.tick(), Some(Event::PressRelease { x: 10, y: 20 }));
692 }
693
694 #[test]
695 fn non_pointer_events_pass_through() {
696 let mut tap = TapRecognizer::new(30);
697 assert_eq!(tap.process(&Event::Tick), Some(Event::Tick));
698 }
699
700 #[test]
701 fn settle_ticks_scale_with_frame_rate() {
702 // At 6 Hz: ceil(200*6/1000) = 2 ticks
703 assert_eq!(ms_to_ticks(SETTLE_MS, 6), 2);
704 // At 30 Hz: ceil(200*30/1000) = 6 ticks
705 assert_eq!(ms_to_ticks(SETTLE_MS, 30), 6);
706 // At 60 Hz: ceil(200*60/1000) = 12 ticks
707 assert_eq!(ms_to_ticks(SETTLE_MS, 60), 12);
708 }
709
710 // ── DoubleTapRecognizer tests ──────────────────────────────────────
711
712 /// Helper: simulate a complete short tap through the double-tap recognizer.
713 /// Advances `hold_ticks` between PressDown and PressRelease.
714 fn short_tap(dtap: &mut DoubleTapRecognizer, x: i32, y: i32, hold_ticks: u8) -> Vec<Event> {
715 let mut out = Vec::new();
716 let (e1, e2) = dtap.process(&Event::PressDown { x, y });
717 if let Some(e) = e1 {
718 out.push(e);
719 }
720 if let Some(e) = e2 {
721 out.push(e);
722 }
723 for _ in 0..hold_ticks {
724 if let Some(e) = dtap.tick() {
725 out.push(e);
726 }
727 }
728 let (e1, e2) = dtap.process(&Event::PressRelease { x, y });
729 if let Some(e) = e1 {
730 out.push(e);
731 }
732 if let Some(e) = e2 {
733 out.push(e);
734 }
735 out
736 }
737
738 #[test]
739 fn double_tap_emits_double_tap_event() {
740 let mut dtap = DoubleTapRecognizer::new(30);
741
742 // First short tap — buffered, no PressRelease emitted
743 let events = short_tap(&mut dtap, 100, 200, 2);
744 // Only PressDown should come through
745 assert_eq!(events, vec![Event::PressDown { x: 100, y: 200 }]);
746
747 // Small gap
748 for _ in 0..3 {
749 assert_eq!(dtap.tick(), None);
750 }
751
752 // Second short tap at same position → DoubleTap
753 let events = short_tap(&mut dtap, 100, 200, 2);
754 assert!(events.contains(&Event::DoubleTap { x: 100, y: 200 }));
755 }
756
757 #[test]
758 fn single_tap_emits_after_timeout() {
759 let mut dtap = DoubleTapRecognizer::new(30);
760
761 // One short tap — buffered
762 let events = short_tap(&mut dtap, 50, 60, 1);
763 assert_eq!(events, vec![Event::PressDown { x: 50, y: 60 }]);
764
765 // Wait for window to expire
766 let window = ms_to_ticks(DOUBLE_TAP_WINDOW_MS, 30);
767 let mut released = false;
768 for _ in 0..window {
769 if let Some(e) = dtap.tick() {
770 assert_eq!(e, Event::PressRelease { x: 50, y: 60 });
771 released = true;
772 }
773 }
774 assert!(released, "buffered PressRelease should emit on timeout");
775 }
776
777 #[test]
778 fn long_press_passes_through_immediately() {
779 let mut dtap = DoubleTapRecognizer::new(30);
780 let long_hold = ms_to_ticks(SHORT_PRESS_MAX_MS, 30) + 5;
781
782 let events = short_tap(&mut dtap, 100, 200, long_hold);
783 // PressDown + PressRelease should both come through (not buffered)
784 assert!(events.contains(&Event::PressDown { x: 100, y: 200 }));
785 assert!(events.contains(&Event::PressRelease { x: 100, y: 200 }));
786 }
787
788 #[test]
789 fn distance_rejection() {
790 let mut dtap = DoubleTapRecognizer::new(30);
791
792 // First tap
793 short_tap(&mut dtap, 10, 10, 1);
794
795 // Second tap far away — should NOT produce DoubleTap
796 let far = DOUBLE_TAP_MAX_DISTANCE + 10;
797 let events = short_tap(&mut dtap, 10 + far, 10, 1);
798 assert!(!events.iter().any(|e| matches!(e, Event::DoubleTap { .. })));
799 }
800
801 // ── DragRecognizer tests (INPUT-00 §12) ────────────────────────────
802
803 /// The canonical two-stage chain (INPUT-00 §6.1/§6.4): raw → Drag →
804 /// Tap, with the mandatory `tap.cancel()` on `DragStart`. DoubleTap
805 /// is exercised separately in the coexistence test.
806 struct DragTapChain {
807 drag: DragRecognizer,
808 tap: TapRecognizer,
809 }
810
811 impl DragTapChain {
812 fn new() -> Self {
813 Self {
814 drag: DragRecognizer::new(),
815 tap: TapRecognizer::new(30),
816 }
817 }
818
819 fn feed(&mut self, event: &Event) -> Vec<Event> {
820 let mut out = Vec::new();
821 if let Some(stage) = self.drag.process(event) {
822 if matches!(stage, Event::DragStart { .. }) {
823 self.tap.cancel();
824 }
825 if let Some(gesture) = self.tap.process(&stage) {
826 out.push(gesture);
827 }
828 }
829 out
830 }
831
832 fn tick(&mut self) -> Vec<Event> {
833 let mut out = Vec::new();
834 if let Some(e) = self.tap.tick() {
835 out.push(e);
836 }
837 if let Some(e) = self.drag.tick() {
838 out.push(e);
839 }
840 out
841 }
842 }
843
844 fn is_drag(e: &Event) -> bool {
845 matches!(
846 e,
847 Event::DragStart { .. } | Event::DragMove { .. } | Event::DragEnd { .. }
848 )
849 }
850
851 #[test]
852 fn sub_threshold_wander_yields_press_release_and_no_drag() {
853 let mut chain = DragTapChain::new();
854 let mut events = Vec::new();
855
856 events.extend(chain.feed(&Event::PointerDown { x: 100, y: 100 }));
857 // Wander below 10 px Euclidean (max displacement 9 px on x).
858 for (x, y) in [(103, 102), (106, 104), (109, 100), (104, 103)] {
859 events.extend(chain.feed(&Event::PointerMove { x, y }));
860 }
861 events.extend(chain.feed(&Event::PointerUp { x: 104, y: 103 }));
862 for _ in 0..ms_to_ticks(SETTLE_MS, 30) {
863 events.extend(chain.tick());
864 }
865
866 assert!(
867 events
868 .iter()
869 .any(|e| matches!(e, Event::PressRelease { .. })),
870 "wandering tap still releases: {events:?}"
871 );
872 assert!(
873 !events.iter().any(is_drag),
874 "no drag events below threshold: {events:?}"
875 );
876 }
877
878 #[test]
879 fn threshold_crossing_emits_drag_with_origin_and_no_press_release() {
880 let mut chain = DragTapChain::new();
881 let mut events = Vec::new();
882
883 events.extend(chain.feed(&Event::PointerDown { x: 100, y: 100 }));
884 events.extend(chain.feed(&Event::PointerMove { x: 105, y: 103 })); // 34 < 100
885 events.extend(chain.feed(&Event::PointerMove { x: 108, y: 106 })); // 100 >= 100
886 events.extend(chain.feed(&Event::PointerMove { x: 140, y: 90 }));
887 events.extend(chain.feed(&Event::PointerUp { x: 150, y: 80 }));
888 // Drain any pending tap settle — nothing may emerge.
889 for _ in 0..ms_to_ticks(SETTLE_MS, 30) + 2 {
890 events.extend(chain.tick());
891 }
892
893 let drags: Vec<&Event> = events.iter().filter(|e| is_drag(e)).collect();
894 assert_eq!(
895 drags,
896 vec![
897 &Event::DragStart {
898 x: 108,
899 y: 106,
900 origin_x: 100,
901 origin_y: 100
902 },
903 &Event::DragMove { x: 140, y: 90 },
904 &Event::DragEnd { x: 150, y: 80 },
905 ],
906 "start-at-origin / move / end sequencing"
907 );
908 assert!(
909 !events
910 .iter()
911 .any(|e| matches!(e, Event::PressRelease { .. })),
912 "click-vs-drag suppression: no PressRelease: {events:?}"
913 );
914 // PressDown (visual feedback) is NOT suppressed (INPUT-00 §6.3).
915 assert!(
916 events.iter().any(|e| matches!(e, Event::PressDown { .. })),
917 "PressDown still emitted at contact"
918 );
919 }
920
921 #[test]
922 fn threshold_metric_is_euclidean_not_manhattan() {
923 // (7, 7): Manhattan 14 ≥ 10 but Euclidean² 98 < 100 — no drag.
924 let mut drag = DragRecognizer::new();
925 drag.process(&Event::PointerDown { x: 0, y: 0 });
926 let out = drag.process(&Event::PointerMove { x: 7, y: 7 });
927 assert_eq!(out, Some(Event::PointerMove { x: 7, y: 7 }));
928 assert!(!drag.is_dragging());
929 // (8, 7): 113 ≥ 100 — drag starts (and ≥ is inclusive: (6, 8) =
930 // 100 would too).
931 let out = drag.process(&Event::PointerMove { x: 8, y: 7 });
932 assert_eq!(
933 out,
934 Some(Event::DragStart {
935 x: 8,
936 y: 7,
937 origin_x: 0,
938 origin_y: 0
939 })
940 );
941 assert!(drag.is_dragging());
942 }
943
944 #[test]
945 fn custom_threshold_and_inclusive_boundary() {
946 let mut drag = DragRecognizer::with_threshold(5);
947 drag.process(&Event::PointerDown { x: 10, y: 10 });
948 let out = drag.process(&Event::PointerMove { x: 13, y: 14 }); // 9+16=25 == 5²
949 assert!(matches!(out, Some(Event::DragStart { .. })), "{out:?}");
950 }
951
952 #[test]
953 fn pointer_up_while_armed_passes_through_to_tap_path() {
954 let mut drag = DragRecognizer::new();
955 drag.process(&Event::PointerDown { x: 5, y: 5 });
956 let out = drag.process(&Event::PointerUp { x: 6, y: 6 });
957 assert_eq!(out, Some(Event::PointerUp { x: 6, y: 6 }));
958 assert!(!drag.is_dragging());
959 }
960
961 #[test]
962 fn pointer_down_while_dragging_rearms_defensively() {
963 let mut drag = DragRecognizer::new();
964 drag.process(&Event::PointerDown { x: 0, y: 0 });
965 drag.process(&Event::PointerMove { x: 20, y: 0 });
966 assert!(drag.is_dragging());
967 // Lost-up glitch: a fresh PointerDown re-arms at the new origin.
968 let out = drag.process(&Event::PointerDown { x: 50, y: 50 });
969 assert_eq!(out, Some(Event::PointerDown { x: 50, y: 50 }));
970 assert!(!drag.is_dragging());
971 let out = drag.process(&Event::PointerMove { x: 56, y: 58 }); // 36+64=100
972 assert_eq!(
973 out,
974 Some(Event::DragStart {
975 x: 56,
976 y: 58,
977 origin_x: 50,
978 origin_y: 50
979 })
980 );
981 }
982
983 #[test]
984 fn non_pointer_events_pass_through_drag_recognizer() {
985 let mut drag = DragRecognizer::new();
986 assert_eq!(drag.process(&Event::Tick), Some(Event::Tick));
987 assert_eq!(drag.tick(), None, "no timers in v1");
988 }
989
990 #[test]
991 fn multi_recognizer_coexistence_full_chain() {
992 // Full canonical chain: raw → Drag → Tap → DoubleTap.
993 let mut drag = DragRecognizer::new();
994 let mut tap = TapRecognizer::new(30);
995 let mut dtap = DoubleTapRecognizer::new(30);
996
997 let feed = |drag: &mut DragRecognizer,
998 tap: &mut TapRecognizer,
999 dtap: &mut DoubleTapRecognizer,
1000 event: &Event|
1001 -> Vec<Event> {
1002 let mut out = Vec::new();
1003 if let Some(stage) = drag.process(event) {
1004 if matches!(stage, Event::DragStart { .. }) {
1005 tap.cancel();
1006 }
1007 if let Some(gesture) = tap.process(&stage) {
1008 let (a, b) = dtap.process(&gesture);
1009 out.extend(a);
1010 out.extend(b);
1011 }
1012 }
1013 out
1014 };
1015 let tick = |tap: &mut TapRecognizer, dtap: &mut DoubleTapRecognizer| -> Vec<Event> {
1016 let mut out = Vec::new();
1017 if let Some(gesture) = tap.tick() {
1018 let (a, b) = dtap.process(&gesture);
1019 out.extend(a);
1020 out.extend(b);
1021 }
1022 out.extend(dtap.tick());
1023 out
1024 };
1025
1026 let mut events = Vec::new();
1027
1028 // 1) A drag: must produce only drag events.
1029 events.extend(feed(
1030 &mut drag,
1031 &mut tap,
1032 &mut dtap,
1033 &Event::PointerDown { x: 10, y: 10 },
1034 ));
1035 events.extend(feed(
1036 &mut drag,
1037 &mut tap,
1038 &mut dtap,
1039 &Event::PointerMove { x: 40, y: 10 },
1040 ));
1041 events.extend(feed(
1042 &mut drag,
1043 &mut tap,
1044 &mut dtap,
1045 &Event::PointerUp { x: 60, y: 10 },
1046 ));
1047 for _ in 0..20 {
1048 events.extend(tick(&mut tap, &mut dtap));
1049 }
1050 assert!(events.iter().any(|e| matches!(e, Event::DragEnd { .. })));
1051 assert!(
1052 !events
1053 .iter()
1054 .any(|e| matches!(e, Event::PressRelease { .. } | Event::DoubleTap { .. })),
1055 "drag emitted tap-family events: {events:?}"
1056 );
1057
1058 // 2) Two stationary short taps afterwards: double-tap still
1059 // recognizes — the drag (and its tap.cancel) did not corrupt
1060 // the downstream recognizers.
1061 events.clear();
1062 for _ in 0..2 {
1063 events.extend(feed(
1064 &mut drag,
1065 &mut tap,
1066 &mut dtap,
1067 &Event::PointerDown { x: 100, y: 100 },
1068 ));
1069 events.extend(feed(
1070 &mut drag,
1071 &mut tap,
1072 &mut dtap,
1073 &Event::PointerUp { x: 100, y: 100 },
1074 ));
1075 // Settle the tap, stay inside the double-tap window.
1076 for _ in 0..ms_to_ticks(SETTLE_MS, 30) {
1077 events.extend(tick(&mut tap, &mut dtap));
1078 }
1079 }
1080 assert!(
1081 events
1082 .iter()
1083 .any(|e| matches!(e, Event::DoubleTap { x: 100, y: 100 })),
1084 "double-tap survives drag coexistence: {events:?}"
1085 );
1086 assert!(
1087 !events.iter().any(is_drag),
1088 "stationary taps emitted drag events"
1089 );
1090 }
1091
1092 // ── LongPressRecognizer tests (LPAR-04 §9) ────────────────────────
1093
1094 /// Drive a contact hold for `hold_ticks` ticks and collect all output.
1095 ///
1096 /// Sends `PressDown`, then calls `tick()` `hold_ticks` times, collecting
1097 /// any `LongPress`/`LongPressRepeat` output along the way.
1098 fn hold_contact(lp: &mut LongPressRecognizer, x: i32, y: i32, hold_ticks: u32) -> Vec<Event> {
1099 let mut out = Vec::new();
1100 lp.process(&Event::PressDown { x, y });
1101 for _ in 0..hold_ticks {
1102 if let Some(e) = lp.tick() {
1103 out.push(e);
1104 }
1105 }
1106 out
1107 }
1108
1109 #[test]
1110 fn long_press_fires_once_then_repeats() {
1111 // Config: long_press after 5 ticks, repeat every 3 ticks.
1112 let mut lp = LongPressRecognizer::with_config(LongPressConfig {
1113 long_press_ticks: 5,
1114 repeat_ticks: 3,
1115 });
1116 // Hold for 5 + 3 + 3 = 11 ticks → LongPress at tick 5, repeats at 8 and 11.
1117 let events = hold_contact(&mut lp, 10, 20, 11);
1118 assert_eq!(
1119 events,
1120 vec![
1121 Event::LongPress { x: 10, y: 20 },
1122 Event::LongPressRepeat { x: 10, y: 20 },
1123 Event::LongPressRepeat { x: 10, y: 20 },
1124 ],
1125 "exact emission ticks: LongPress at threshold, repeats at each interval"
1126 );
1127 }
1128
1129 #[test]
1130 fn release_before_threshold_emits_no_long_press() {
1131 let mut lp = LongPressRecognizer::with_config(LongPressConfig {
1132 long_press_ticks: 10,
1133 repeat_ticks: 3,
1134 });
1135 // Hold for 9 ticks (one short of threshold), then release.
1136 let events = hold_contact(&mut lp, 5, 5, 9);
1137 lp.process(&Event::PressRelease { x: 5, y: 5 });
1138 // No long press should have fired.
1139 assert!(
1140 events.is_empty(),
1141 "release before threshold must not emit LongPress: {events:?}"
1142 );
1143 // No output after release either.
1144 for _ in 0..5 {
1145 assert_eq!(lp.tick(), None, "nothing after release");
1146 }
1147 }
1148
1149 #[test]
1150 fn drag_start_cancels_long_press() {
1151 let mut lp = LongPressRecognizer::with_config(LongPressConfig {
1152 long_press_ticks: 5,
1153 repeat_ticks: 3,
1154 });
1155 lp.process(&Event::PressDown { x: 0, y: 0 });
1156 // Advance 3 ticks (below threshold).
1157 for _ in 0..3 {
1158 assert_eq!(lp.tick(), None);
1159 }
1160 // DragStart disarms.
1161 lp.process(&Event::DragStart {
1162 x: 20,
1163 y: 0,
1164 origin_x: 0,
1165 origin_y: 0,
1166 });
1167 // Continue ticking past the threshold — must produce nothing.
1168 for _ in 0..10 {
1169 assert_eq!(
1170 lp.tick(),
1171 None,
1172 "DragStart must disarm the long-press recognizer"
1173 );
1174 }
1175 }
1176
1177 #[test]
1178 fn determinism_identical_scripts_produce_identical_output() {
1179 let config = LongPressConfig {
1180 long_press_ticks: 4,
1181 repeat_ticks: 2,
1182 };
1183
1184 let run = || {
1185 let mut lp = LongPressRecognizer::with_config(config);
1186 let mut out = Vec::new();
1187 lp.process(&Event::PressDown { x: 7, y: 8 });
1188 for _ in 0..8 {
1189 if let Some(e) = lp.tick() {
1190 out.push(e);
1191 }
1192 }
1193 lp.process(&Event::PressRelease { x: 7, y: 8 });
1194 for _ in 0..3 {
1195 assert_eq!(lp.tick(), None);
1196 }
1197 out
1198 };
1199
1200 let first = run();
1201 let second = run();
1202 assert_eq!(
1203 first, second,
1204 "identical input+tick sequences must produce identical output"
1205 );
1206 }
1207
1208 #[test]
1209 fn long_press_does_not_emit_tap_events() {
1210 // The long-press recognizer is purely additive: it must never emit
1211 // PressRelease or any tap-family event (LPAR-04 §9.5).
1212 let mut lp = LongPressRecognizer::with_config(LongPressConfig {
1213 long_press_ticks: 3,
1214 repeat_ticks: 2,
1215 });
1216 let events = hold_contact(&mut lp, 50, 60, 10);
1217 lp.process(&Event::PointerUp { x: 50, y: 60 });
1218 // process() always returns the event unchanged — check it passes PressRelease through.
1219 let pass = lp.process(&Event::PressRelease { x: 50, y: 60 });
1220 // After disarm, tick must return None.
1221 for _ in 0..5 {
1222 assert_eq!(lp.tick(), None);
1223 }
1224 // None of the long-press ticked outputs should be tap events.
1225 for e in &events {
1226 assert!(
1227 matches!(e, Event::LongPress { .. } | Event::LongPressRepeat { .. }),
1228 "recognizer emitted unexpected event from tick: {e:?}"
1229 );
1230 }
1231 // process() must pass through PressRelease unmodified (purely additive).
1232 assert_eq!(
1233 pass,
1234 Some(Event::PressRelease { x: 50, y: 60 }),
1235 "process() must always pass events through"
1236 );
1237 }
1238
1239 #[test]
1240 fn position_tracks_drag_move_while_armed() {
1241 let mut lp = LongPressRecognizer::with_config(LongPressConfig {
1242 long_press_ticks: 4,
1243 repeat_ticks: 2,
1244 });
1245 // Arm at (0,0), then update position via DragMove (below drag threshold
1246 // scenario is exercised by process passthrough; this tests tracking).
1247 lp.process(&Event::PressDown { x: 0, y: 0 });
1248 lp.process(&Event::DragMove { x: 3, y: 4 });
1249 // Tick past threshold.
1250 let mut out = Vec::new();
1251 for _ in 0..4 {
1252 if let Some(e) = lp.tick() {
1253 out.push(e);
1254 }
1255 }
1256 assert_eq!(
1257 out,
1258 vec![Event::LongPress { x: 3, y: 4 }],
1259 "LongPress coordinates must reflect last DragMove position"
1260 );
1261 }
1262
1263 #[test]
1264 fn pointer_up_disarms_recognizer() {
1265 let mut lp = LongPressRecognizer::with_config(LongPressConfig {
1266 long_press_ticks: 5,
1267 repeat_ticks: 2,
1268 });
1269 lp.process(&Event::PressDown { x: 1, y: 2 });
1270 for _ in 0..3 {
1271 lp.tick();
1272 }
1273 lp.process(&Event::PointerUp { x: 1, y: 2 });
1274 for _ in 0..10 {
1275 assert_eq!(lp.tick(), None, "PointerUp must disarm");
1276 }
1277 }
1278
1279 #[test]
1280 fn non_pointer_events_pass_through_long_press_recognizer() {
1281 let mut lp = LongPressRecognizer::new();
1282 assert_eq!(lp.process(&Event::Tick), Some(Event::Tick));
1283 }
1284}