Skip to main content

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}