umsh_ux_display_tracker/
gate.rs

1//! Input gating: when a button gesture means something other than what
2//! it would normally mean.
3//!
4//! Three conditions make a gesture unsafe to act on literally:
5//!
6//! - [`GateReason::ScreenOff`] — the user cannot see what they would be
7//!   acting on, so the press only brings the display back.
8//! - [`GateReason::AlertActive`] — a locate alert is running, and
9//!   whoever just found the device meant to silence it, not to navigate
10//!   its menus.
11//! - [`GateReason::Refreshing`] — a persistent panel is mid-refresh, so
12//!   the visible selection and the acted-on selection could differ.
13//!
14//! One gesture always passes through regardless:
15//! [`ButtonEvent::VeryLong`], the power-off hold. It is deliberate
16//! enough to mean it, it is the documented escape from every state, and
17//! requiring a lit screen first would make a dark unresponsive device
18//! impossible to turn off.
19//!
20//! # Latching
21//!
22//! The decision is latched at the **press** that starts a gesture, not
23//! at the event that ends it. A double-click begun against a dark panel
24//! resolves several hundred milliseconds later, by which time the panel
25//! is lit again; without the latch it would both wake the display and
26//! select something. Feed [`Gate::on_press`] on each press edge and
27//! [`Gate::settle`] once the recognizer comes to rest, and the whole
28//! chord is judged by the conditions that held when it began.
29
30use umsh_ux_tracker::button::ButtonEvent;
31
32/// A condition that changes what a gesture means.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum GateReason {
35    /// An emissive panel has been powered off.
36    ScreenOff,
37    /// A locate alert is running.
38    AlertActive,
39    /// A persistent panel has not finished drawing.
40    Refreshing,
41}
42
43impl GateReason {
44    const fn bit(self) -> u8 {
45        1 << self as u8
46    }
47}
48
49/// What to do with a resolved gesture.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum Disposition {
52    /// Hand it to the menu.
53    Deliver,
54    /// Swallow it: the gesture began against a dark panel and has
55    /// already done its job by waking it.
56    ConsumedByWake,
57    /// Swallow it and cancel the running locate alert instead.
58    CancelAlert,
59    /// Swallow it: the panel could not show what was being acted on.
60    Discard,
61}
62
63/// Input gate.
64///
65/// Pure logic, no clock: the conditions are set by whoever owns them and
66/// the latch is driven by the button recognizer's edges.
67#[derive(Clone, Copy, Debug, Default)]
68pub struct Gate {
69    current: u8,
70    latched: Option<u8>,
71}
72
73impl Gate {
74    pub const fn new() -> Self {
75        Self {
76            current: 0,
77            latched: None,
78        }
79    }
80
81    /// Assert or release a condition.
82    pub fn set(&mut self, reason: GateReason, active: bool) {
83        if active {
84            self.current |= reason.bit();
85        } else {
86            self.current &= !reason.bit();
87        }
88    }
89
90    pub fn is_set(&self, reason: GateReason) -> bool {
91        self.current & reason.bit() != 0
92    }
93
94    /// Whether any condition currently gates input.
95    pub fn is_gating(&self) -> bool {
96        self.current != 0
97    }
98
99    /// Latch the current conditions at the start of a gesture.
100    ///
101    /// Call on every debounced press edge; presses that continue an
102    /// in-progress chord are ignored, so the first one decides.
103    pub fn on_press(&mut self) {
104        if self.latched.is_none() {
105            self.latched = Some(self.current);
106        }
107    }
108
109    /// Release the latch once the recognizer is at rest.
110    ///
111    /// Pass `umsh_ux_tracker::button::ButtonFsm::next_deadline().is_none()`.
112    /// This covers gestures that end without producing an event — a hold
113    /// too long to be a click and too short to be a long-press — which
114    /// would otherwise strand the latch and swallow the *next* gesture.
115    pub fn settle(&mut self, resting: bool) {
116        if resting {
117            self.latched = None;
118        }
119    }
120
121    /// Decide what a resolved gesture means.
122    ///
123    /// Judged against the latched conditions when a gesture is in
124    /// progress, and against the live ones otherwise.
125    pub fn disposition(&self, event: ButtonEvent) -> Disposition {
126        if matches!(event, ButtonEvent::VeryLong) {
127            return Disposition::Deliver;
128        }
129        let reasons = self.latched.unwrap_or(self.current);
130        if reasons & GateReason::AlertActive.bit() != 0 {
131            Disposition::CancelAlert
132        } else if reasons & GateReason::ScreenOff.bit() != 0 {
133            Disposition::ConsumedByWake
134        } else if reasons & GateReason::Refreshing.bit() != 0 {
135            Disposition::Discard
136        } else {
137            Disposition::Deliver
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn an_ungated_gesture_is_delivered() {
148        let mut g = Gate::new();
149        g.on_press();
150        assert_eq!(g.disposition(ButtonEvent::Single), Disposition::Deliver);
151        assert!(!g.is_gating());
152    }
153
154    #[test]
155    fn a_gesture_begun_against_a_dark_panel_only_wakes_it() {
156        let mut g = Gate::new();
157        g.set(GateReason::ScreenOff, true);
158        g.on_press();
159        assert_eq!(
160            g.disposition(ButtonEvent::Single),
161            Disposition::ConsumedByWake
162        );
163    }
164
165    #[test]
166    fn the_whole_chord_is_judged_by_its_first_press() {
167        let mut g = Gate::new();
168        g.set(GateReason::ScreenOff, true);
169        g.on_press();
170        // The press woke the panel, so the condition clears mid-gesture.
171        g.set(GateReason::ScreenOff, false);
172        g.on_press(); // second press of a double-click
173        assert_eq!(
174            g.disposition(ButtonEvent::Double),
175            Disposition::ConsumedByWake
176        );
177    }
178
179    #[test]
180    fn the_next_gesture_is_judged_afresh() {
181        let mut g = Gate::new();
182        g.set(GateReason::ScreenOff, true);
183        g.on_press();
184        assert_eq!(
185            g.disposition(ButtonEvent::Single),
186            Disposition::ConsumedByWake
187        );
188        g.set(GateReason::ScreenOff, false);
189        g.settle(true);
190
191        g.on_press();
192        assert_eq!(g.disposition(ButtonEvent::Single), Disposition::Deliver);
193    }
194
195    #[test]
196    fn a_gesture_that_resolves_to_nothing_does_not_strand_the_latch() {
197        let mut g = Gate::new();
198        g.set(GateReason::ScreenOff, true);
199        g.on_press();
200        // Held too long for a click, too short for a long-press: the
201        // recognizer returns to rest without emitting anything.
202        g.set(GateReason::ScreenOff, false);
203        g.settle(true);
204
205        g.on_press();
206        assert_eq!(g.disposition(ButtonEvent::Single), Disposition::Deliver);
207    }
208
209    #[test]
210    fn settle_while_a_chord_is_pending_keeps_the_latch() {
211        let mut g = Gate::new();
212        g.set(GateReason::ScreenOff, true);
213        g.on_press();
214        g.set(GateReason::ScreenOff, false);
215        // Recognizer still waiting for a possible second click.
216        g.settle(false);
217        assert_eq!(
218            g.disposition(ButtonEvent::Double),
219            Disposition::ConsumedByWake
220        );
221    }
222
223    #[test]
224    fn an_alert_turns_every_gesture_into_a_cancel() {
225        let mut g = Gate::new();
226        g.set(GateReason::AlertActive, true);
227        g.on_press();
228        for event in [
229            ButtonEvent::Single,
230            ButtonEvent::Double,
231            ButtonEvent::Triple,
232            ButtonEvent::Quad,
233            ButtonEvent::Long,
234        ] {
235            assert_eq!(g.disposition(event), Disposition::CancelAlert);
236        }
237    }
238
239    #[test]
240    fn power_off_always_passes_through() {
241        let mut g = Gate::new();
242        for reason in [
243            GateReason::ScreenOff,
244            GateReason::AlertActive,
245            GateReason::Refreshing,
246        ] {
247            g.set(reason, true);
248        }
249        g.on_press();
250        assert_eq!(g.disposition(ButtonEvent::VeryLong), Disposition::Deliver);
251    }
252
253    #[test]
254    fn a_refreshing_panel_discards_input() {
255        let mut g = Gate::new();
256        g.set(GateReason::Refreshing, true);
257        g.on_press();
258        assert_eq!(g.disposition(ButtonEvent::Single), Disposition::Discard);
259    }
260
261    #[test]
262    fn cancelling_an_alert_outranks_a_dark_panel() {
263        let mut g = Gate::new();
264        g.set(GateReason::ScreenOff, true);
265        g.set(GateReason::AlertActive, true);
266        g.on_press();
267        assert_eq!(g.disposition(ButtonEvent::Single), Disposition::CancelAlert);
268    }
269
270    #[test]
271    fn an_unlatched_gesture_falls_back_to_live_conditions() {
272        let mut g = Gate::new();
273        g.set(GateReason::AlertActive, true);
274        assert_eq!(g.disposition(ButtonEvent::Single), Disposition::CancelAlert);
275    }
276}