umsh_ux_tracker/
button.rs

1//! Button event recognition state machine.
2//!
3//! Resolves a stream of raw [`ButtonEdge`] events plus a monotonic
4//! millisecond clock into the high-level [`ButtonEvent`]s the UX is
5//! defined in terms of: single, double, triple, and quadruple clicks,
6//! plus long-press and an optional very-long-press.
7//!
8//! The machine is **pure logic** — no embassy, no hardware, no I/O — so
9//! it can be exhaustively unit-tested with synthetic time. Callers are
10//! expected to:
11//!
12//! 1. Call [`ButtonFsm::on_edge`] for each debounced press / release.
13//! 2. Call [`ButtonFsm::poll`] when the deadline reported by
14//!    [`ButtonFsm::next_deadline`] elapses.
15//!
16//! Both methods can produce a [`ButtonEvent`] when one becomes
17//! resolvable.
18//!
19//! # Recognition rules
20//!
21//! - A "click" is a press-then-release where the hold duration is at
22//!   most `max_click_hold`.
23//! - One to four clicks within `inter_click_gap` of each other produce
24//!   [`ButtonEvent::Single`], [`ButtonEvent::Double`],
25//!   [`ButtonEvent::Triple`], or [`ButtonEvent::Quad`] respectively.
26//!   Quad fires immediately on the fourth release without waiting for
27//!   the gap; single / double / triple fire after the gap elapses with
28//!   no further press.
29//! - By default, holding the button continuously for `long_press` produces
30//!   [`ButtonEvent::Long`] *while the button is still pressed* and consumes
31//!   any clicks accumulated before the hold began.
32//! - When `very_long_press` is configured, releasing between `long_press`
33//!   and `very_long_press` produces [`ButtonEvent::Long`]. Remaining held
34//!   through `very_long_press` produces [`ButtonEvent::VeryLong`] without
35//!   first emitting `Long`. This supports a navigational hold plus a distinct
36//!   always-available sleep hold.
37//! - Releases longer than `max_click_hold` but shorter than
38//!   `long_press` are *discarded clicks*; if there are accumulated
39//!   prior clicks they are emitted, otherwise nothing fires. This
40//!   matches the rule: a press that's "too long to be a click but too
41//!   short to be a long-press" is a user error and should not silently
42//!   become either.
43
44use core::time::Duration;
45
46/// Raw debounced button transition.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ButtonEdge {
49    /// Button transitioned from released to pressed.
50    Press,
51    /// Button transitioned from pressed to released.
52    Release,
53}
54
55/// High-level button event recognized by the FSM.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ButtonEvent {
58    Single,
59    Double,
60    Triple,
61    Quad,
62    Long,
63    VeryLong,
64}
65
66/// Tunable timings; tune on real hardware.
67#[derive(Debug, Clone, Copy)]
68pub struct ButtonTimings {
69    /// Longest hold that still counts as a click (rather than a discard
70    /// or a long-press).
71    pub max_click_hold: Duration,
72    /// Maximum gap from a release to the next press to count as part of
73    /// the same click sequence.
74    pub inter_click_gap: Duration,
75    /// Continuous hold duration that triggers [`ButtonEvent::Long`].
76    pub long_press: Duration,
77    /// Optional second hold threshold. When present, `Long` is emitted on
78    /// release after `long_press`, while [`ButtonEvent::VeryLong`] fires at
79    /// this deadline while the button remains held.
80    pub very_long_press: Option<Duration>,
81}
82
83impl Default for ButtonTimings {
84    fn default() -> Self {
85        Self {
86            max_click_hold: Duration::from_millis(500),
87            inter_click_gap: Duration::from_millis(400),
88            long_press: Duration::from_secs(3),
89            very_long_press: None,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum State {
96    /// No press in progress, no pending click sequence.
97    Idle,
98    /// Button currently held. `pressed_at` is the press timestamp,
99    /// `prior_clicks` is the count of completed clicks before this press.
100    Pressed { pressed_at: u64, prior_clicks: u8 },
101    /// Button released after a valid click, waiting to see whether the
102    /// next press arrives within `inter_click_gap`.
103    WaitingForNext { released_at: u64, clicks: u8 },
104    /// Long-press has already fired; suppress everything until release.
105    LongFired,
106}
107
108/// Button event recognition state machine.
109#[derive(Debug)]
110pub struct ButtonFsm {
111    timings: ButtonTimings,
112    state: State,
113}
114
115impl ButtonFsm {
116    pub fn new(timings: ButtonTimings) -> Self {
117        Self {
118            timings,
119            state: State::Idle,
120        }
121    }
122
123    /// Feed a debounced edge. Returns an event if one is resolvable
124    /// purely from the edge (without waiting for a timeout).
125    pub fn on_edge(&mut self, edge: ButtonEdge, now_ms: u64) -> Option<ButtonEvent> {
126        match (self.state, edge) {
127            // First press of a new sequence.
128            (State::Idle, ButtonEdge::Press) => {
129                self.state = State::Pressed {
130                    pressed_at: now_ms,
131                    prior_clicks: 0,
132                };
133                None
134            }
135
136            // Subsequent press in an in-progress click chord.
137            (State::WaitingForNext { clicks, .. }, ButtonEdge::Press) => {
138                self.state = State::Pressed {
139                    pressed_at: now_ms,
140                    prior_clicks: clicks,
141                };
142                None
143            }
144
145            // Release after a press: classify the hold duration.
146            (
147                State::Pressed {
148                    pressed_at,
149                    prior_clicks,
150                },
151                ButtonEdge::Release,
152            ) => self.classify_release(pressed_at, prior_clicks, now_ms),
153
154            // Release after long-press: reset.
155            (State::LongFired, ButtonEdge::Release) => {
156                self.state = State::Idle;
157                None
158            }
159
160            // Glitchy duplicate edges — ignore.
161            (State::Pressed { .. }, ButtonEdge::Press) => None,
162            (State::WaitingForNext { .. }, ButtonEdge::Release) => None,
163            (State::Idle, ButtonEdge::Release) => None,
164            (State::LongFired, ButtonEdge::Press) => None,
165        }
166    }
167
168    /// Advance time without an edge. Returns an event if a timeout
169    /// resolves one (long-press while held, or single/double after the
170    /// inter-click gap).
171    pub fn poll(&mut self, now_ms: u64) -> Option<ButtonEvent> {
172        match self.state {
173            State::Pressed { pressed_at, .. }
174                if elapsed(pressed_at, now_ms)
175                    >= self
176                        .timings
177                        .very_long_press
178                        .unwrap_or(self.timings.long_press) =>
179            {
180                self.state = State::LongFired;
181                Some(if self.timings.very_long_press.is_some() {
182                    ButtonEvent::VeryLong
183                } else {
184                    ButtonEvent::Long
185                })
186            }
187
188            State::WaitingForNext {
189                released_at,
190                clicks,
191            } if elapsed(released_at, now_ms) >= self.timings.inter_click_gap => {
192                self.state = State::Idle;
193                click_count_to_event(clicks)
194            }
195
196            _ => None,
197        }
198    }
199
200    /// Returns the absolute monotonic-millisecond deadline at which
201    /// [`poll`](Self::poll) should next be called, if any.
202    pub fn next_deadline(&self) -> Option<u64> {
203        match self.state {
204            State::Pressed { pressed_at, .. } => {
205                let deadline = self
206                    .timings
207                    .very_long_press
208                    .unwrap_or(self.timings.long_press);
209                Some(pressed_at + deadline.as_millis() as u64)
210            }
211            State::WaitingForNext { released_at, .. } => {
212                Some(released_at + self.timings.inter_click_gap.as_millis() as u64)
213            }
214            State::Idle | State::LongFired => None,
215        }
216    }
217
218    fn classify_release(
219        &mut self,
220        pressed_at: u64,
221        prior_clicks: u8,
222        now_ms: u64,
223    ) -> Option<ButtonEvent> {
224        let hold = elapsed(pressed_at, now_ms);
225
226        if let Some(very_long) = self.timings.very_long_press {
227            if hold >= very_long {
228                // The deadline should normally fire from poll. Preserve the
229                // event if the caller only observes the eventual release.
230                self.state = State::Idle;
231                return Some(ButtonEvent::VeryLong);
232            }
233            if hold >= self.timings.long_press {
234                self.state = State::Idle;
235                return Some(ButtonEvent::Long);
236            }
237        }
238
239        if self.timings.very_long_press.is_none() && hold >= self.timings.long_press {
240            // Long-press should have already fired in poll; on the off
241            // chance it didn't (e.g. poll wasn't called), fire it now.
242            self.state = State::Idle;
243            return Some(ButtonEvent::Long);
244        }
245
246        if hold > self.timings.max_click_hold {
247            // Held too long for a click, too short for a long-press.
248            // Emit any accumulated prior clicks and reset.
249            self.state = State::Idle;
250            return click_count_to_event(prior_clicks);
251        }
252
253        // Valid click.
254        let clicks = prior_clicks.saturating_add(1);
255        if clicks >= 4 {
256            self.state = State::Idle;
257            return Some(ButtonEvent::Quad);
258        }
259        self.state = State::WaitingForNext {
260            released_at: now_ms,
261            clicks,
262        };
263        None
264    }
265}
266
267fn elapsed(since_ms: u64, now_ms: u64) -> Duration {
268    Duration::from_millis(now_ms.saturating_sub(since_ms))
269}
270
271fn click_count_to_event(clicks: u8) -> Option<ButtonEvent> {
272    match clicks {
273        1 => Some(ButtonEvent::Single),
274        2 => Some(ButtonEvent::Double),
275        3 => Some(ButtonEvent::Triple),
276        4 => Some(ButtonEvent::Quad),
277        _ => None,
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    /// FSM with fixed test timings (500 ms click, 400 ms gap, 5 s long).
286    ///
287    /// Deliberately *not* `ButtonTimings::default()`: the assertions below
288    /// hardcode thresholds derived from these numbers, and the product
289    /// defaults are tuning knobs that have changed before (long-press
290    /// 5 s → 3 s in commit 65d8d4e6, which silently broke this module's
291    /// tests). Pinning the timings keeps the tests about the FSM's
292    /// *logic*, not the current tuning.
293    fn fsm() -> ButtonFsm {
294        ButtonFsm::new(ButtonTimings {
295            max_click_hold: Duration::from_millis(500),
296            inter_click_gap: Duration::from_millis(400),
297            long_press: Duration::from_secs(5),
298            very_long_press: None,
299        })
300    }
301
302    /// Simulate a press at `down_ms` and release at `up_ms` with no
303    /// intervening polls. Returns the edge events plus the result of a
304    /// final `poll` at `up_ms + 500ms` (which is past the inter-click
305    /// gap, so any pending single/double/triple should fire).
306    fn click(
307        fsm: &mut ButtonFsm,
308        down_ms: u64,
309        up_ms: u64,
310    ) -> (Option<ButtonEvent>, Option<ButtonEvent>) {
311        let on_press = fsm.on_edge(ButtonEdge::Press, down_ms);
312        let on_release = fsm.on_edge(ButtonEdge::Release, up_ms);
313        (on_press, on_release)
314    }
315
316    #[test]
317    fn idle_release_is_ignored() {
318        let mut fsm = fsm();
319        assert_eq!(fsm.on_edge(ButtonEdge::Release, 0), None);
320    }
321
322    #[test]
323    fn single_click_fires_after_gap() {
324        let mut fsm = fsm();
325        let (p, r) = click(&mut fsm, 0, 100);
326        assert_eq!(p, None);
327        assert_eq!(r, None);
328
329        // Before the gap, nothing.
330        assert_eq!(fsm.poll(300), None);
331
332        // After the gap, single fires.
333        assert_eq!(fsm.poll(600), Some(ButtonEvent::Single));
334        assert_eq!(fsm.poll(700), None);
335    }
336
337    #[test]
338    fn double_click_fires_after_gap() {
339        let mut fsm = fsm();
340        click(&mut fsm, 0, 100);
341        click(&mut fsm, 200, 300);
342
343        assert_eq!(fsm.poll(400), None);
344        assert_eq!(fsm.poll(800), Some(ButtonEvent::Double));
345    }
346
347    #[test]
348    fn triple_click_fires_after_gap() {
349        // Since Quad was added, a triple no longer fires immediately on
350        // the third release — the FSM must wait out the inter-click gap
351        // in case a fourth click arrives.
352        let mut fsm = fsm();
353        click(&mut fsm, 0, 100);
354        click(&mut fsm, 200, 300);
355        let (_, third_release) = click(&mut fsm, 400, 500);
356        assert_eq!(third_release, None);
357
358        // Before the gap, nothing.
359        assert_eq!(fsm.poll(700), None);
360
361        // After the gap, triple fires.
362        assert_eq!(fsm.poll(900), Some(ButtonEvent::Triple));
363        assert_eq!(fsm.poll(1_000), None);
364    }
365
366    #[test]
367    fn long_press_fires_while_held() {
368        let mut fsm = fsm();
369        assert_eq!(fsm.on_edge(ButtonEdge::Press, 0), None);
370
371        // Before the threshold, nothing.
372        assert_eq!(fsm.poll(4_999), None);
373
374        // At the threshold, long fires.
375        assert_eq!(fsm.poll(5_000), Some(ButtonEvent::Long));
376
377        // Subsequent polls while still held don't re-fire.
378        assert_eq!(fsm.poll(6_000), None);
379
380        // Eventual release returns to idle without producing anything.
381        assert_eq!(fsm.on_edge(ButtonEdge::Release, 7_000), None);
382        assert_eq!(fsm.poll(8_000), None);
383    }
384
385    #[test]
386    fn long_press_consumes_prior_clicks() {
387        let mut fsm = fsm();
388        click(&mut fsm, 0, 100); // accumulate one click
389        assert_eq!(fsm.on_edge(ButtonEdge::Press, 200), None);
390
391        // Hold for 5 seconds → Long, prior click is lost.
392        assert_eq!(fsm.poll(5_200), Some(ButtonEvent::Long));
393        assert_eq!(fsm.on_edge(ButtonEdge::Release, 5_300), None);
394    }
395
396    #[test]
397    fn long_press_fires_on_release_if_poll_was_missed() {
398        let mut fsm = fsm();
399        fsm.on_edge(ButtonEdge::Press, 0);
400        // No poll. Release after the long-press threshold.
401        assert_eq!(
402            fsm.on_edge(ButtonEdge::Release, 6_000),
403            Some(ButtonEvent::Long)
404        );
405    }
406
407    #[test]
408    fn two_stage_hold_emits_long_on_release_without_firing_early() {
409        let mut fsm = ButtonFsm::new(ButtonTimings {
410            max_click_hold: Duration::from_millis(500),
411            inter_click_gap: Duration::from_millis(400),
412            long_press: Duration::from_secs(1),
413            very_long_press: Some(Duration::from_secs(4)),
414        });
415        fsm.on_edge(ButtonEdge::Press, 0);
416
417        // Crossing the navigation-hold threshold while still pressed does
418        // not emit anything; the user can continue holding for sleep.
419        assert_eq!(fsm.poll(1_000), None);
420        assert_eq!(fsm.poll(2_500), None);
421        assert_eq!(
422            fsm.on_edge(ButtonEdge::Release, 2_500),
423            Some(ButtonEvent::Long)
424        );
425    }
426
427    #[test]
428    fn two_stage_hold_emits_only_very_long_at_second_deadline() {
429        let mut fsm = ButtonFsm::new(ButtonTimings {
430            max_click_hold: Duration::from_millis(500),
431            inter_click_gap: Duration::from_millis(400),
432            long_press: Duration::from_secs(1),
433            very_long_press: Some(Duration::from_secs(4)),
434        });
435        fsm.on_edge(ButtonEdge::Press, 0);
436
437        assert_eq!(fsm.next_deadline(), Some(4_000));
438        assert_eq!(fsm.poll(3_999), None);
439        assert_eq!(fsm.poll(4_000), Some(ButtonEvent::VeryLong));
440        assert_eq!(fsm.poll(5_000), None);
441        assert_eq!(fsm.on_edge(ButtonEdge::Release, 5_100), None);
442    }
443
444    #[test]
445    fn two_stage_very_long_survives_a_missed_poll() {
446        let mut fsm = ButtonFsm::new(ButtonTimings {
447            max_click_hold: Duration::from_millis(500),
448            inter_click_gap: Duration::from_millis(400),
449            long_press: Duration::from_secs(1),
450            very_long_press: Some(Duration::from_secs(4)),
451        });
452        fsm.on_edge(ButtonEdge::Press, 0);
453        assert_eq!(
454            fsm.on_edge(ButtonEdge::Release, 4_500),
455            Some(ButtonEvent::VeryLong)
456        );
457    }
458
459    #[test]
460    fn hold_between_click_and_long_press_discards_click_with_no_priors() {
461        let mut fsm = fsm();
462        // Held 1 s — too long for a click, too short for long-press.
463        fsm.on_edge(ButtonEdge::Press, 0);
464        assert_eq!(fsm.on_edge(ButtonEdge::Release, 1_000), None);
465        assert_eq!(fsm.poll(2_000), None);
466    }
467
468    #[test]
469    fn hold_between_click_and_long_press_emits_prior_clicks() {
470        let mut fsm = fsm();
471        // One valid click.
472        click(&mut fsm, 0, 100);
473        // Then a too-long second press: prior single should fire, second discarded.
474        fsm.on_edge(ButtonEdge::Press, 200);
475        let release = fsm.on_edge(ButtonEdge::Release, 1_500);
476        assert_eq!(release, Some(ButtonEvent::Single));
477    }
478
479    #[test]
480    fn next_deadline_tracks_long_press_while_held() {
481        let mut fsm = fsm();
482        fsm.on_edge(ButtonEdge::Press, 1_000);
483        assert_eq!(fsm.next_deadline(), Some(6_000)); // 1_000 + 5_000
484    }
485
486    #[test]
487    fn next_deadline_tracks_gap_while_waiting() {
488        let mut fsm = fsm();
489        click(&mut fsm, 0, 100);
490        assert_eq!(fsm.next_deadline(), Some(500)); // released_at=100 + gap=400
491    }
492
493    #[test]
494    fn next_deadline_is_none_when_idle() {
495        let fsm = fsm();
496        assert_eq!(fsm.next_deadline(), None);
497    }
498
499    #[test]
500    fn duplicate_press_edges_are_ignored() {
501        let mut fsm = fsm();
502        assert_eq!(fsm.on_edge(ButtonEdge::Press, 0), None);
503        assert_eq!(fsm.on_edge(ButtonEdge::Press, 50), None);
504        // Release normally; should still register as a single click.
505        assert_eq!(fsm.on_edge(ButtonEdge::Release, 100), None);
506        assert_eq!(fsm.poll(600), Some(ButtonEvent::Single));
507    }
508
509    #[test]
510    fn glitch_release_in_idle_is_ignored() {
511        let mut fsm = fsm();
512        assert_eq!(fsm.on_edge(ButtonEdge::Release, 0), None);
513        assert_eq!(fsm.poll(1_000), None);
514    }
515
516    #[test]
517    fn four_quick_clicks_resolve_as_quad_immediately() {
518        // Quad is the highest click count, so it fires immediately on the
519        // fourth release without waiting for the inter-click gap.
520        let mut fsm = fsm();
521        click(&mut fsm, 0, 50);
522        click(&mut fsm, 100, 150);
523        let (_, third) = click(&mut fsm, 200, 250);
524        assert_eq!(third, None);
525        let (_, quad) = click(&mut fsm, 300, 350);
526        assert_eq!(quad, Some(ButtonEvent::Quad));
527
528        // The sequence is consumed: nothing further fires.
529        assert_eq!(fsm.poll(800), None);
530        assert_eq!(fsm.next_deadline(), None);
531    }
532}