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
15use rlvgl_core::event::Event;
16
17// ── Duration constants (tunable, frame-rate independent) ───────────────────
18
19/// Debounce settle period for `TapRecognizer` — long enough to absorb
20/// FT5336 capacitive touch bounce.
21pub const SETTLE_MS: u32 = 200;
22
23/// Maximum hold duration (PressDown → PressRelease) for a tap to count as
24/// "short". Taps held longer than this are treated as long-presses and will
25/// not arm the double-tap detector.
26pub const SHORT_PRESS_MAX_MS: u32 = 250;
27
28/// Maximum gap between two consecutive short taps for the pair to be
29/// recognized as a double-tap. Starts counting from the first PressRelease.
30pub const DOUBLE_TAP_WINDOW_MS: u32 = 400;
31
32/// Maximum spatial distance (Manhattan) between two taps for them to count
33/// as a double-tap. Prevents accidental double-taps on different list rows.
34pub const DOUBLE_TAP_MAX_DISTANCE: i32 = 20;
35
36/// Convert a duration in milliseconds to tick counts at the given frame rate,
37/// rounding up so we never undercount.
38fn ms_to_ticks(ms: u32, frame_hz: u32) -> u8 {
39    (ms * frame_hz).div_ceil(1000) as u8
40}
41
42// ── TapRecognizer ──────────────────────────────────────────────────────────
43
44/// Tap gesture recognizer with duration-based settle period.
45///
46/// Converts raw `PointerDown`/`PointerUp` into debounced `PressDown`/`PressRelease`.
47/// Feed raw events via [`process`](Self::process) and call [`tick`](Self::tick)
48/// each frame to advance the settle timer.
49pub struct TapRecognizer {
50    state: TapState,
51    /// Position of the current/pending contact.
52    pos: (i32, i32),
53    /// Ticks remaining before firing PressRelease.
54    settle: u8,
55    /// Settle duration in ticks (derived from [`SETTLE_MS`] and frame rate).
56    max_settle: u8,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum TapState {
61    /// No active contact.
62    Idle,
63    /// Contact is active (PressDown already emitted).
64    Down,
65    /// PointerUp received, waiting for settle before emitting PressRelease.
66    PendingRelease,
67}
68
69impl TapRecognizer {
70    /// Create a new recognizer. Settle duration is derived from [`SETTLE_MS`]
71    /// at the given frame rate.
72    pub fn new(frame_hz: u32) -> Self {
73        Self {
74            state: TapState::Idle,
75            pos: (0, 0),
76            settle: 0,
77            max_settle: ms_to_ticks(SETTLE_MS, frame_hz),
78        }
79    }
80
81    /// Process a raw input event. Returns a gesture event to dispatch,
82    /// or `None` if the event was consumed internally.
83    ///
84    /// Only `PointerDown`, `PointerUp`, and `PointerMove` are processed.
85    /// All other events pass through unchanged.
86    pub fn process(&mut self, event: &Event) -> Option<Event> {
87        match event {
88            Event::PointerDown { x, y } => {
89                self.pos = (*x, *y);
90                match self.state {
91                    TapState::Idle => {
92                        // Fresh contact — emit PressDown for visual feedback
93                        self.state = TapState::Down;
94                        Some(Event::PressDown { x: *x, y: *y })
95                    }
96                    TapState::Down => {
97                        // Already down — update position (drag start)
98                        None
99                    }
100                    TapState::PendingRelease => {
101                        // Bounce! New PointerDown during settle — go back to Down
102                        self.state = TapState::Down;
103                        self.settle = 0;
104                        None // don't re-emit PressDown, it was already sent
105                    }
106                }
107            }
108            Event::PointerUp { x, y } => {
109                self.pos = (*x, *y);
110                match self.state {
111                    TapState::Down => {
112                        // Start settle timer — don't emit PressRelease yet
113                        self.state = TapState::PendingRelease;
114                        self.settle = self.max_settle;
115                        None
116                    }
117                    TapState::PendingRelease => {
118                        // Another PointerUp during settle — update position, reset timer
119                        self.settle = self.max_settle;
120                        None
121                    }
122                    TapState::Idle => {
123                        // Spurious PointerUp with no prior Down — ignore
124                        None
125                    }
126                }
127            }
128            Event::PointerMove { x, y } => {
129                if self.state == TapState::Down {
130                    self.pos = (*x, *y);
131                }
132                // Pass through as-is for widgets that want move tracking
133                Some(event.clone())
134            }
135            // All other events pass through unchanged
136            _ => Some(event.clone()),
137        }
138    }
139
140    /// Advance the settle timer. Call once per Tick.
141    ///
142    /// Returns `PressRelease` when the settle period expires, or `None`.
143    pub fn tick(&mut self) -> Option<Event> {
144        if self.state == TapState::PendingRelease {
145            if self.settle > 0 {
146                self.settle -= 1;
147            }
148            if self.settle == 0 {
149                self.state = TapState::Idle;
150                let (x, y) = self.pos;
151                return Some(Event::PressRelease { x, y });
152            }
153        }
154        None
155    }
156}
157
158// ── DoubleTapRecognizer ────────────────────────────────────────────────────
159
160/// Double-tap gesture recognizer.
161///
162/// Sits downstream of [`TapRecognizer`], consuming `PressDown`/`PressRelease`
163/// events and emitting `DoubleTap` when two consecutive short taps are
164/// detected within the time and distance window.
165///
166/// The first `PressRelease` is buffered — it is only forwarded after the
167/// double-tap window expires without a second tap. This adds up to
168/// [`DOUBLE_TAP_WINDOW_MS`] of latency to single taps.
169pub struct DoubleTapRecognizer {
170    state: DtState,
171    /// Position of the buffered first tap.
172    armed_pos: (i32, i32),
173    /// Ticks remaining in the double-tap window.
174    countdown: u8,
175    /// Tick count when the most recent PressDown arrived (for hold measurement).
176    down_tick: u8,
177    /// Running tick counter, wraps at u8::MAX.
178    tick_counter: u8,
179    // Derived thresholds
180    short_press_max_ticks: u8,
181    window_ticks: u8,
182    max_distance: i32,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum DtState {
187    /// Waiting for a short tap.
188    Idle,
189    /// First short tap received and buffered. Waiting for second tap or timeout.
190    Armed,
191}
192
193impl DoubleTapRecognizer {
194    /// Create a new recognizer with thresholds derived from the duration
195    /// constants at the given frame rate.
196    pub fn new(frame_hz: u32) -> Self {
197        Self {
198            state: DtState::Idle,
199            armed_pos: (0, 0),
200            countdown: 0,
201            down_tick: 0,
202            tick_counter: 0,
203            short_press_max_ticks: ms_to_ticks(SHORT_PRESS_MAX_MS, frame_hz),
204            window_ticks: ms_to_ticks(DOUBLE_TAP_WINDOW_MS, frame_hz),
205            max_distance: DOUBLE_TAP_MAX_DISTANCE,
206        }
207    }
208
209    /// Process a gesture event (output of [`TapRecognizer`]).
210    ///
211    /// Returns up to two events to dispatch. The second slot is used when an
212    /// out-of-range second tap arrives: the buffered first `PressRelease` is
213    /// emitted, and the recognizer re-arms with the new position.
214    pub fn process(&mut self, event: &Event) -> (Option<Event>, Option<Event>) {
215        match event {
216            Event::PressDown { .. } => {
217                // Record when the finger went down for hold-duration measurement
218                self.down_tick = self.tick_counter;
219                (Some(event.clone()), None)
220            }
221            Event::PressRelease { x, y } => {
222                let hold = self.tick_counter.wrapping_sub(self.down_tick);
223                let is_short = hold <= self.short_press_max_ticks;
224
225                match self.state {
226                    DtState::Idle => {
227                        if is_short {
228                            // Buffer this tap and arm the double-tap detector
229                            self.state = DtState::Armed;
230                            self.armed_pos = (*x, *y);
231                            self.countdown = self.window_ticks;
232                            (None, None) // suppress — will emit on timeout or double
233                        } else {
234                            // Long press — pass through immediately
235                            (Some(event.clone()), None)
236                        }
237                    }
238                    DtState::Armed => {
239                        let (ax, ay) = self.armed_pos;
240                        let dist = (ax - *x).abs() + (ay - *y).abs();
241
242                        if is_short && dist <= self.max_distance {
243                            // Double tap! Emit DoubleTap, suppress both PressRelease.
244                            self.state = DtState::Idle;
245                            self.countdown = 0;
246                            (Some(Event::DoubleTap { x: *x, y: *y }), None)
247                        } else {
248                            // Too far or too slow — emit the buffered first tap
249                            let first = Event::PressRelease { x: ax, y: ay };
250                            if is_short {
251                                // Re-arm with the new position
252                                self.armed_pos = (*x, *y);
253                                self.countdown = self.window_ticks;
254                                (Some(first), None)
255                            } else {
256                                // Long press — emit both and go idle
257                                self.state = DtState::Idle;
258                                self.countdown = 0;
259                                (Some(first), Some(event.clone()))
260                            }
261                        }
262                    }
263                }
264            }
265            // All other events pass through
266            _ => (Some(event.clone()), None),
267        }
268    }
269
270    /// Advance the double-tap window timer. Call once per Tick.
271    ///
272    /// Returns the buffered `PressRelease` when the window expires without a
273    /// second tap.
274    pub fn tick(&mut self) -> Option<Event> {
275        self.tick_counter = self.tick_counter.wrapping_add(1);
276
277        if self.state == DtState::Armed {
278            if self.countdown > 0 {
279                self.countdown -= 1;
280            }
281            if self.countdown == 0 {
282                self.state = DtState::Idle;
283                let (x, y) = self.armed_pos;
284                return Some(Event::PressRelease { x, y });
285            }
286        }
287        None
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use alloc::vec;
295    use alloc::vec::Vec;
296
297    // ── TapRecognizer tests ────────────────────────────────────────────
298
299    #[test]
300    fn tap_produces_press_down_then_release() {
301        let mut tap = TapRecognizer::new(30);
302
303        // PointerDown → PressDown immediately
304        let result = tap.process(&Event::PointerDown { x: 100, y: 200 });
305        assert_eq!(result, Some(Event::PressDown { x: 100, y: 200 }));
306
307        // PointerUp → queued, no output yet
308        let result = tap.process(&Event::PointerUp { x: 100, y: 200 });
309        assert_eq!(result, None);
310
311        // Tick through settle period (SETTLE_MS=200 at 30Hz = 6 ticks)
312        for _ in 0..5 {
313            assert_eq!(tap.tick(), None);
314        }
315        let result = tap.tick();
316        assert_eq!(result, Some(Event::PressRelease { x: 100, y: 200 }));
317
318        // Subsequent ticks — idle
319        assert_eq!(tap.tick(), None);
320    }
321
322    #[test]
323    fn bounce_suppressed() {
324        let mut tap = TapRecognizer::new(30);
325
326        tap.process(&Event::PointerDown { x: 10, y: 20 });
327        tap.process(&Event::PointerUp { x: 10, y: 20 });
328
329        // Bounce: new PointerDown before settle
330        let result = tap.process(&Event::PointerDown { x: 10, y: 20 });
331        assert_eq!(result, None);
332
333        tap.process(&Event::PointerUp { x: 10, y: 20 });
334
335        // Settle
336        let settle_ticks = ms_to_ticks(SETTLE_MS, 30);
337        for _ in 0..(settle_ticks - 1) {
338            assert_eq!(tap.tick(), None);
339        }
340        assert_eq!(tap.tick(), Some(Event::PressRelease { x: 10, y: 20 }));
341    }
342
343    #[test]
344    fn non_pointer_events_pass_through() {
345        let mut tap = TapRecognizer::new(30);
346        assert_eq!(tap.process(&Event::Tick), Some(Event::Tick));
347    }
348
349    #[test]
350    fn settle_ticks_scale_with_frame_rate() {
351        // At 6 Hz: ceil(200*6/1000) = 2 ticks
352        assert_eq!(ms_to_ticks(SETTLE_MS, 6), 2);
353        // At 30 Hz: ceil(200*30/1000) = 6 ticks
354        assert_eq!(ms_to_ticks(SETTLE_MS, 30), 6);
355        // At 60 Hz: ceil(200*60/1000) = 12 ticks
356        assert_eq!(ms_to_ticks(SETTLE_MS, 60), 12);
357    }
358
359    // ── DoubleTapRecognizer tests ──────────────────────────────────────
360
361    /// Helper: simulate a complete short tap through the double-tap recognizer.
362    /// Advances `hold_ticks` between PressDown and PressRelease.
363    fn short_tap(dtap: &mut DoubleTapRecognizer, x: i32, y: i32, hold_ticks: u8) -> Vec<Event> {
364        let mut out = Vec::new();
365        let (e1, e2) = dtap.process(&Event::PressDown { x, y });
366        if let Some(e) = e1 {
367            out.push(e);
368        }
369        if let Some(e) = e2 {
370            out.push(e);
371        }
372        for _ in 0..hold_ticks {
373            if let Some(e) = dtap.tick() {
374                out.push(e);
375            }
376        }
377        let (e1, e2) = dtap.process(&Event::PressRelease { x, y });
378        if let Some(e) = e1 {
379            out.push(e);
380        }
381        if let Some(e) = e2 {
382            out.push(e);
383        }
384        out
385    }
386
387    #[test]
388    fn double_tap_emits_double_tap_event() {
389        let mut dtap = DoubleTapRecognizer::new(30);
390
391        // First short tap — buffered, no PressRelease emitted
392        let events = short_tap(&mut dtap, 100, 200, 2);
393        // Only PressDown should come through
394        assert_eq!(events, vec![Event::PressDown { x: 100, y: 200 }]);
395
396        // Small gap
397        for _ in 0..3 {
398            assert_eq!(dtap.tick(), None);
399        }
400
401        // Second short tap at same position → DoubleTap
402        let events = short_tap(&mut dtap, 100, 200, 2);
403        assert!(events.contains(&Event::DoubleTap { x: 100, y: 200 }));
404    }
405
406    #[test]
407    fn single_tap_emits_after_timeout() {
408        let mut dtap = DoubleTapRecognizer::new(30);
409
410        // One short tap — buffered
411        let events = short_tap(&mut dtap, 50, 60, 1);
412        assert_eq!(events, vec![Event::PressDown { x: 50, y: 60 }]);
413
414        // Wait for window to expire
415        let window = ms_to_ticks(DOUBLE_TAP_WINDOW_MS, 30);
416        let mut released = false;
417        for _ in 0..window {
418            if let Some(e) = dtap.tick() {
419                assert_eq!(e, Event::PressRelease { x: 50, y: 60 });
420                released = true;
421            }
422        }
423        assert!(released, "buffered PressRelease should emit on timeout");
424    }
425
426    #[test]
427    fn long_press_passes_through_immediately() {
428        let mut dtap = DoubleTapRecognizer::new(30);
429        let long_hold = ms_to_ticks(SHORT_PRESS_MAX_MS, 30) + 5;
430
431        let events = short_tap(&mut dtap, 100, 200, long_hold);
432        // PressDown + PressRelease should both come through (not buffered)
433        assert!(events.contains(&Event::PressDown { x: 100, y: 200 }));
434        assert!(events.contains(&Event::PressRelease { x: 100, y: 200 }));
435    }
436
437    #[test]
438    fn distance_rejection() {
439        let mut dtap = DoubleTapRecognizer::new(30);
440
441        // First tap
442        short_tap(&mut dtap, 10, 10, 1);
443
444        // Second tap far away — should NOT produce DoubleTap
445        let far = DOUBLE_TAP_MAX_DISTANCE + 10;
446        let events = short_tap(&mut dtap, 10 + far, 10, 1);
447        assert!(!events.iter().any(|e| matches!(e, Event::DoubleTap { .. })));
448    }
449}