ados-hmi 0.1.1

Input state machines for hardware front panels: touchscreen affine calibration and gestures, button press classification, quadrature encoder decoding and a pilot-in-command arbiter.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
//! Touch gestures: calibrated samples in, classified gestures out.
//!
//! Covers the per-stroke state machine (`open_stroke` / `record_move` /
//! `close_stroke`), the move filter, and the tap / long_press / swipe / drag
//! classifier. Map each raw `(x, y)` sample to display pixels with
//! [`crate::affine::Affine::apply`] before feeding the FSM.
//!
//! [`StrokeFsm`] is pure and host-portable: it takes transformed
//! `(x_lcd, y_lcd, ts_ms)` samples, so the move-delta filter, the
//! displacement/duration thresholds and the direction-of-larger-component rule
//! are all unit-testable with synthetic strokes.

/// Move filter: only record a sample when it differs from the previous accepted
/// one by at least this many pixels on either axis (stops a held-stationary pen
/// from flooding the move stream).
pub const MOVE_DELTA_PX: i32 = 2;

/// A contact under this duration AND under [`TAP_DISPLACEMENT_PX`] is a tap;
/// at/over this duration it is a long press.
pub const TAP_DUR_MS: i64 = 400;

/// Displacement floor (LCD px) separating a tap/long_press from a swipe/drag.
pub const TAP_DISPLACEMENT_PX: f64 = 12.0;

/// A contact under this duration with displacement >= [`SWIPE_DISPLACEMENT_PX`]
/// is a swipe; otherwise (with motion) it is a drag.
pub const SWIPE_DUR_MS: i64 = 250;

/// Displacement floor (LCD px) for a fast contact to count as a swipe.
pub const SWIPE_DISPLACEMENT_PX: f64 = 24.0;

/// Most move samples one stroke keeps. A pen held down on a jittery digitiser
/// would otherwise grow the stroke for as long as it stays down. The first
/// sample (the stroke's origin) is always kept; past the cap the oldest sample
/// after it is dropped, so the stroke carries its origin plus the most recent
/// path.
pub const MAX_STROKE_SAMPLES: usize = 512;

/// The classified kind of a completed stroke.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GestureKind {
    /// Short, nearly stationary contact.
    Tap,
    /// Long, nearly stationary contact.
    LongPress,
    /// Fast directional stroke.
    Swipe,
    /// Slow directional stroke.
    Drag,
}

impl GestureKind {
    /// The lowercase wire string.
    pub fn as_str(self) -> &'static str {
        match self {
            GestureKind::Tap => "tap",
            GestureKind::LongPress => "long_press",
            GestureKind::Swipe => "swipe",
            GestureKind::Drag => "drag",
        }
    }
}

/// Cardinal direction of the larger displacement component. Set for swipes and
/// drags; `None` for tap/long_press.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
    /// Toward the top edge.
    Up,
    /// Toward the bottom edge.
    Down,
    /// Toward the left edge.
    Left,
    /// Toward the right edge.
    Right,
}

impl Direction {
    /// The lowercase wire string.
    pub fn as_str(self) -> &'static str {
        match self {
            Direction::Up => "up",
            Direction::Down => "down",
            Direction::Left => "left",
            Direction::Right => "right",
        }
    }
}

/// A single accepted move sample in LCD pixel space.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TouchMove {
    /// x in display pixels.
    pub x_lcd: i32,
    /// y in display pixels.
    pub y_lcd: i32,
    /// Unix milliseconds of the event.
    pub timestamp_ms: i64,
}

/// A completed pen-down -> pen-up sequence, classified into a kind.
#[derive(Debug, Clone, PartialEq)]
pub struct TouchGesture {
    /// What happened.
    pub kind: GestureKind,
    /// Stroke start x in pixels.
    pub start_x: i32,
    /// Stroke start y in pixels.
    pub start_y: i32,
    /// Stroke end x in pixels.
    pub end_x: i32,
    /// Stroke end y in pixels.
    pub end_y: i32,
    /// Pen-down time in milliseconds.
    pub start_t_ms: i64,
    /// Pen-up time in milliseconds.
    pub end_t_ms: i64,
    /// Stroke duration in milliseconds.
    pub duration_ms: i64,
    /// Direction of the larger displacement, for swipes and drags.
    pub direction: Option<Direction>,
    /// Mean speed over the stroke in pixels per second.
    pub velocity_px_per_s: f64,
    /// The stroke's accepted moves: the first one plus up to
    /// [`MAX_STROKE_SAMPLES`] - 1 of the most recent.
    pub samples: Vec<TouchMove>,
}

/// One step the FSM emits as it consumes a transformed sample. The bridge maps
/// `Move` onto its live move stream and `Gesture` onto the gesture stream.
#[derive(Debug, Clone, PartialEq)]
pub enum StrokeStep {
    /// An accepted move sample passed the delta filter.
    Move(TouchMove),
    /// A pen-up closed the stroke into a classified gesture.
    Gesture(TouchGesture),
}

/// Pure per-stroke state machine + gesture classifier. The bridge feeds it
/// pen-down / transformed-move / pen-up events; it tracks the stroke and emits
/// move + gesture steps. No evdev, no IO, fully unit-testable.
#[derive(Debug, Default)]
pub struct StrokeFsm {
    down_at_ms: i64,
    down_x_lcd: i32,
    down_y_lcd: i32,
    last_x_lcd: i32,
    last_y_lcd: i32,
    samples: std::collections::VecDeque<TouchMove>,
    pen_down: bool,
}

impl StrokeFsm {
    /// A stroke machine with no stroke open.
    pub fn new() -> Self {
        Self::default()
    }

    /// Whether the pen is currently down (a stroke is open).
    pub fn pen_down(&self) -> bool {
        self.pen_down
    }

    /// Begin a stroke at `now_ms`. Coordinate seeding is deferred to the first
    /// accepted move (only then is there a sample).
    pub fn open_stroke(&mut self, now_ms: i64) {
        self.down_at_ms = now_ms;
        self.samples.clear();
        self.down_x_lcd = 0;
        self.down_y_lcd = 0;
        self.last_x_lcd = 0;
        self.last_y_lcd = 0;
        self.pen_down = true;
    }

    /// Record a transformed `(x_lcd, y_lcd)` move. The first sample seeds the
    /// down-position and is always accepted; later samples pass only when they
    /// differ from the previous accepted one by >= [`MOVE_DELTA_PX`] on either
    /// axis. Returns the accepted sample, or `None` when filtered.
    pub fn record_move(&mut self, x_lcd: i32, y_lcd: i32, now_ms: i64) -> Option<TouchMove> {
        if self.samples.is_empty() {
            self.down_x_lcd = x_lcd;
            self.down_y_lcd = y_lcd;
            self.last_x_lcd = x_lcd;
            self.last_y_lcd = y_lcd;
            let m = TouchMove {
                x_lcd,
                y_lcd,
                timestamp_ms: now_ms,
            };
            self.samples.push_back(m);
            return Some(m);
        }
        let dx = (x_lcd - self.last_x_lcd).abs();
        let dy = (y_lcd - self.last_y_lcd).abs();
        if dx < MOVE_DELTA_PX && dy < MOVE_DELTA_PX {
            return None;
        }
        self.last_x_lcd = x_lcd;
        self.last_y_lcd = y_lcd;
        let m = TouchMove {
            x_lcd,
            y_lcd,
            timestamp_ms: now_ms,
        };
        if self.samples.len() >= MAX_STROKE_SAMPLES {
            // Index 0 is the origin; drop the oldest sample after it.
            self.samples.remove(1);
        }
        self.samples.push_back(m);
        Some(m)
    }

    /// Close the stroke at `now_ms` and classify it. Returns `None` when no
    /// sample landed before pen-up (a driver quirk, nothing to emit). Resets
    /// the FSM to the pen-up state.
    pub fn close_stroke(&mut self, now_ms: i64) -> Option<TouchGesture> {
        self.pen_down = false;
        let last = self.samples.back().copied()?;
        let end_x_lcd = last.x_lcd;
        let end_y_lcd = last.y_lcd;
        let duration_ms = (now_ms - self.down_at_ms).max(0);
        let dx_total = end_x_lcd - self.down_x_lcd;
        let dy_total = end_y_lcd - self.down_y_lcd;
        let displacement = ((dx_total * dx_total + dy_total * dy_total) as f64).sqrt();
        let velocity = if duration_ms > 0 {
            displacement * 1000.0 / duration_ms as f64
        } else {
            0.0
        };
        let kind = classify(duration_ms, displacement);
        let direction = match kind {
            GestureKind::Swipe | GestureKind::Drag => Some(direction_for(dx_total, dy_total)),
            _ => None,
        };
        let gesture = TouchGesture {
            kind,
            start_x: self.down_x_lcd,
            start_y: self.down_y_lcd,
            end_x: end_x_lcd,
            end_y: end_y_lcd,
            start_t_ms: self.down_at_ms,
            end_t_ms: now_ms,
            duration_ms,
            direction,
            velocity_px_per_s: velocity,
            samples: Vec::from(std::mem::take(&mut self.samples)),
        };
        Some(gesture)
    }
}

/// Classify a stroke by duration + total displacement. Under the displacement floor it
/// is a tap/long_press by duration; a fast long-enough move is a swipe; otherwise a
/// drag.
pub fn classify(duration_ms: i64, displacement: f64) -> GestureKind {
    if displacement < TAP_DISPLACEMENT_PX {
        return if duration_ms >= TAP_DUR_MS {
            GestureKind::LongPress
        } else {
            GestureKind::Tap
        };
    }
    if duration_ms < SWIPE_DUR_MS && displacement >= SWIPE_DISPLACEMENT_PX {
        return GestureKind::Swipe;
    }
    GestureKind::Drag
}

/// Cardinal direction of the larger displacement component (ties favour the
/// horizontal axis, matching `abs(dx) >= abs(dy)`).
pub fn direction_for(dx: i32, dy: i32) -> Direction {
    if dx.abs() >= dy.abs() {
        if dx >= 0 {
            Direction::Right
        } else {
            Direction::Left
        }
    } else if dy >= 0 {
        Direction::Down
    } else {
        Direction::Up
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::affine::Affine;

    /// Drive a stroke through the FSM with `(x_lcd, y_lcd, ts_ms)` samples
    /// (already transformed) and return the closing gesture plus the count of
    /// accepted move samples.
    fn run_stroke(
        open_ms: i64,
        samples: &[(i32, i32, i64)],
        close_ms: i64,
    ) -> (TouchGesture, usize) {
        let mut fsm = StrokeFsm::new();
        fsm.open_stroke(open_ms);
        let mut moves = 0;
        for &(x, y, t) in samples {
            if fsm.record_move(x, y, t).is_some() {
                moves += 1;
            }
        }
        let g = fsm.close_stroke(close_ms).expect("a gesture");
        (g, moves)
    }

    #[test]
    fn tap_is_short_and_still() {
        // Single point, released 100 ms later -> tap, no direction.
        let (g, moves) = run_stroke(0, &[(100, 100, 0)], 100);
        assert_eq!(g.kind, GestureKind::Tap);
        assert!(g.direction.is_none());
        assert_eq!(moves, 1);
        assert_eq!((g.start_x, g.start_y), (100, 100));
        assert_eq!((g.end_x, g.end_y), (100, 100));
    }

    #[test]
    fn long_press_is_long_and_still() {
        // Held 400 ms at one point (displacement 0) -> long_press.
        let (g, _) = run_stroke(0, &[(100, 100, 0)], 400);
        assert_eq!(g.kind, GestureKind::LongPress);
        assert!(g.direction.is_none());
    }

    #[test]
    fn just_under_long_threshold_is_tap() {
        let (g, _) = run_stroke(0, &[(50, 50, 0)], 399);
        assert_eq!(g.kind, GestureKind::Tap);
    }

    #[test]
    fn small_displacement_under_floor_stays_tap() {
        // 8 px total displacement (< 12) -> still tap even though it moved.
        let (g, _) = run_stroke(0, &[(0, 0, 0), (8, 0, 50)], 100);
        assert_eq!(g.kind, GestureKind::Tap);
    }

    #[test]
    fn fast_long_move_is_a_swipe_right() {
        // 200 ms (< 250) with 40 px displacement (>= 24) -> swipe, +x -> right.
        let (g, _) = run_stroke(0, &[(0, 0, 0), (40, 0, 100)], 200);
        assert_eq!(g.kind, GestureKind::Swipe);
        assert_eq!(g.direction, Some(Direction::Right));
        assert!(g.velocity_px_per_s > 0.0);
    }

    #[test]
    fn swipe_up_when_vertical_component_larger_and_negative() {
        // dy = -50 dominates dx = 10 -> up.
        let (g, _) = run_stroke(0, &[(0, 0, 0), (10, -50, 100)], 200);
        assert_eq!(g.kind, GestureKind::Swipe);
        assert_eq!(g.direction, Some(Direction::Up));
    }

    #[test]
    fn slow_long_move_is_a_drag() {
        // 300 ms (>= swipe dur) with 40 px displacement -> drag, not swipe.
        let (g, _) = run_stroke(0, &[(0, 0, 0), (40, 0, 150)], 300);
        assert_eq!(g.kind, GestureKind::Drag);
        assert_eq!(g.direction, Some(Direction::Right));
    }

    #[test]
    fn fast_short_move_above_tap_floor_below_swipe_floor_is_a_drag() {
        // 100 ms, displacement 16 px: above tap floor (12) but below swipe
        // floor (24) -> drag.
        let (g, _) = run_stroke(0, &[(0, 0, 0), (16, 0, 50)], 100);
        assert_eq!(g.kind, GestureKind::Drag);
    }

    #[test]
    fn move_delta_filter_drops_sub_threshold_samples() {
        let mut fsm = StrokeFsm::new();
        fsm.open_stroke(0);
        // First sample seeds (accepted).
        assert!(fsm.record_move(100, 100, 0).is_some());
        // +1 px on each axis is below the 2 px floor -> dropped.
        assert!(fsm.record_move(101, 101, 10).is_none());
        // +2 px clears the floor -> accepted.
        assert!(fsm.record_move(102, 100, 20).is_some());
    }

    #[test]
    fn close_without_any_sample_emits_nothing() {
        let mut fsm = StrokeFsm::new();
        fsm.open_stroke(0);
        // Pen-up before any ABS sample landed -> no gesture.
        assert!(fsm.close_stroke(100).is_none());
        assert!(!fsm.pen_down());
    }

    #[test]
    fn samples_are_carried_on_the_gesture_and_fsm_resets() {
        let mut fsm = StrokeFsm::new();
        fsm.open_stroke(0);
        fsm.record_move(0, 0, 0);
        fsm.record_move(40, 0, 100);
        let g = fsm.close_stroke(200).unwrap();
        assert_eq!(g.samples.len(), 2);
        // A fresh stroke starts clean.
        fsm.open_stroke(1000);
        fsm.record_move(5, 5, 1000);
        let g2 = fsm.close_stroke(1100).unwrap();
        assert_eq!(g2.samples.len(), 1);
    }

    #[test]
    fn a_long_held_stroke_keeps_its_origin_and_recent_path_within_the_cap() {
        let mut fsm = StrokeFsm::new();
        fsm.open_stroke(0);
        let total = MAX_STROKE_SAMPLES as i32 * 3;
        // Every move clears the delta filter, so each one is accepted.
        for i in 0..total {
            let x = (i % 2) * MOVE_DELTA_PX * 2;
            assert!(fsm.record_move(x, i, i64::from(i)).is_some());
        }
        let g = fsm.close_stroke(i64::from(total)).unwrap();
        assert_eq!(g.samples.len(), MAX_STROKE_SAMPLES);
        assert_eq!(g.samples[0].timestamp_ms, 0, "origin kept");
        assert_eq!(
            g.samples.last().unwrap().timestamp_ms,
            i64::from(total - 1),
            "latest kept"
        );
        assert_eq!(
            g.samples[1].timestamp_ms,
            i64::from(total) - MAX_STROKE_SAMPLES as i64 + 1,
            "the oldest samples after the origin were the ones dropped"
        );
        assert_eq!((g.start_x, g.start_y), (0, 0));
    }

    #[test]
    fn direction_ties_favour_horizontal() {
        // |dx| == |dy| -> horizontal wins (matches abs(dx) >= abs(dy)).
        assert_eq!(direction_for(10, 10), Direction::Right);
        assert_eq!(direction_for(-10, 10), Direction::Left);
        assert_eq!(direction_for(0, 5), Direction::Down);
        assert_eq!(direction_for(0, -5), Direction::Up);
    }

    #[test]
    fn classify_thresholds_at_the_boundaries() {
        // Exactly the tap displacement floor (12) is NOT a tap (>= floor path).
        assert_eq!(classify(100, 12.0), GestureKind::Drag);
        // Just under the floor is a tap.
        assert_eq!(classify(100, 11.9), GestureKind::Tap);
        // At the long-press duration with no displacement.
        assert_eq!(classify(400, 0.0), GestureKind::LongPress);
        // Swipe boundary: dur 249 (< 250), disp 24 (>= 24).
        assert_eq!(classify(249, 24.0), GestureKind::Swipe);
        // dur 250 (not < 250) -> drag even at swipe displacement.
        assert_eq!(classify(250, 24.0), GestureKind::Drag);
    }

    #[test]
    fn affine_transform_feeds_the_fsm() {
        // A 2x scale identity-ish matrix: raw 50 -> 100 px confirms the bridge
        // applies the transform before the FSM classifies.
        let m = Affine {
            a: 2.0,
            b: 0.0,
            c: 0.0,
            d: 0.0,
            e: 2.0,
            f: 0.0,
        };
        let mut fsm = StrokeFsm::new();
        fsm.open_stroke(0);
        let (x0, y0) = m.apply(0, 0);
        let (x1, y1) = m.apply(20, 0); // -> 40 px, a swipe-distance move
        fsm.record_move(x0, y0, 0);
        fsm.record_move(x1, y1, 100);
        let g = fsm.close_stroke(200).unwrap();
        assert_eq!(g.end_x, 40);
        assert_eq!(g.kind, GestureKind::Swipe);
    }

    #[test]
    fn gesture_and_direction_wire_strings() {
        assert_eq!(GestureKind::Tap.as_str(), "tap");
        assert_eq!(GestureKind::LongPress.as_str(), "long_press");
        assert_eq!(GestureKind::Swipe.as_str(), "swipe");
        assert_eq!(GestureKind::Drag.as_str(), "drag");
        assert_eq!(Direction::Up.as_str(), "up");
        assert_eq!(Direction::Down.as_str(), "down");
        assert_eq!(Direction::Left.as_str(), "left");
        assert_eq!(Direction::Right.as_str(), "right");
    }
}