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}