umsh_ux_display_tracker/
menu.rs

1//! On-screen menu policy shared by every display tracker.
2//!
3//! The model is a flat, wrapping list of [`MenuItem`]s with a
4//! confirmation page in front of the destructive ones. Each board
5//! enables the subset it can actually perform via [`MenuItems`];
6//! navigation skips whatever is not enabled, so the same code drives a
7//! two-item Heltec menu and a four-item T-Echo menu without either
8//! firmware knowing the other exists.
9//!
10//! [`MenuItem::Status`] is the home item and is always enabled: it is
11//! where boot starts, where an activated item returns to, and where the
12//! display-attention lapse sends the user back to (see
13//! [`crate::attention`]).
14
15/// One resolved navigation gesture.
16///
17/// Boards with a single button map click / double-click / hold-release
18/// onto these; boards with a D-pad map up / down, press, and a back
19/// button onto the same three. The model never learns which.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum UiInput {
22    Forward,
23    Select,
24    Backward,
25}
26
27/// An entry in the menu.
28///
29/// The enum is the union across all display trackers; a board narrows it
30/// with [`MenuItems`]. Declaration order is navigation order.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum MenuItem {
33    /// Home. Shows the battery, and whatever else is not nominal.
34    Status,
35    /// Radio activity since boot: frame counts, power, duty cycle. A
36    /// page, not an action.
37    Stats,
38    /// Advertise this device's identity now.
39    CheckIn,
40    /// Open a time-limited pairing window for a new companion.
41    StartPairing,
42    /// Forget every bonded companion. Confirmed before it runs.
43    ClearBonds,
44}
45
46impl MenuItem {
47    /// Every item, in navigation order.
48    pub const ALL: [MenuItem; 5] = [
49        MenuItem::Status,
50        MenuItem::Stats,
51        MenuItem::CheckIn,
52        MenuItem::StartPairing,
53        MenuItem::ClearBonds,
54    ];
55
56    const fn bit(self) -> u8 {
57        1 << self as u8
58    }
59
60    const fn index(self) -> usize {
61        self as usize
62    }
63
64    /// Whether selecting this item opens a confirmation page rather than
65    /// acting immediately.
66    ///
67    /// Destructive items confirm; everything else is either harmless or
68    /// trivially reversible.
69    pub const fn requires_confirmation(self) -> bool {
70        matches!(self, MenuItem::ClearBonds)
71    }
72
73    /// What activating this item asks the firmware to do. `Status` and
74    /// `Stats` are inert — they are pages, not actions.
75    pub const fn effect(self) -> Option<UiEffect> {
76        match self {
77            MenuItem::Status | MenuItem::Stats => None,
78            MenuItem::CheckIn => Some(UiEffect::CheckIn),
79            MenuItem::StartPairing => Some(UiEffect::StartPairing),
80            MenuItem::ClearBonds => Some(UiEffect::ClearBonds),
81        }
82    }
83}
84
85/// The set of menu items a board enables.
86///
87/// [`MenuItem::Status`] is always present regardless of how the set was
88/// built: a menu with no home item has nowhere to return to.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct MenuItems(u8);
91
92impl MenuItems {
93    /// Just [`MenuItem::Status`].
94    pub const fn new() -> Self {
95        Self(MenuItem::Status.bit())
96    }
97
98    /// Every item this crate defines.
99    pub const fn all() -> Self {
100        Self(
101            MenuItem::Status.bit()
102                | MenuItem::Stats.bit()
103                | MenuItem::CheckIn.bit()
104                | MenuItem::StartPairing.bit()
105                | MenuItem::ClearBonds.bit(),
106        )
107    }
108
109    /// Enable one more item.
110    pub const fn with(self, item: MenuItem) -> Self {
111        Self(self.0 | item.bit())
112    }
113
114    pub const fn contains(self, item: MenuItem) -> bool {
115        self.0 & item.bit() != 0
116    }
117
118    /// Number of enabled items. Always at least one.
119    pub const fn len(self) -> u32 {
120        self.0.count_ones()
121    }
122
123    pub const fn is_empty(self) -> bool {
124        false
125    }
126
127    /// Step `from` by `step` positions through the enabled items,
128    /// wrapping. `step` is +1 for forward and -1 for backward.
129    fn step(self, from: MenuItem, step: isize) -> MenuItem {
130        let n = MenuItem::ALL.len();
131        let mut index = from.index();
132        // At worst this visits every item once; `Status` is always
133        // enabled, so it always terminates on something.
134        for _ in 0..n {
135            index = (index as isize + step).rem_euclid(n as isize) as usize;
136            let candidate = MenuItem::ALL[index];
137            if self.contains(candidate) {
138                return candidate;
139            }
140        }
141        MenuItem::Status
142    }
143}
144
145impl Default for MenuItems {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// The screen currently being shown.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum Page {
154    /// The menu, with `.0` highlighted.
155    Menu(MenuItem),
156    /// A confirmation for a destructive `item`. `confirm_selected` is
157    /// false while Cancel is the visible choice.
158    Confirm {
159        item: MenuItem,
160        confirm_selected: bool,
161    },
162}
163
164/// Something the firmware should do as a result of a selection.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum UiEffect {
167    CheckIn,
168    StartPairing,
169    ClearBonds,
170}
171
172/// A transient result message shown on the status page.
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub enum UiNotice {
175    CheckInRequested,
176    PairingStarted,
177    PairingUnavailable,
178    BondsCleared,
179    ClearFailed,
180}
181
182/// The menu state machine.
183///
184/// Pure logic: no clock, no I/O. The owning task feeds it resolved
185/// gestures and renders whatever [`page`](Self::page) and
186/// [`notice`](Self::notice) report.
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub struct UiModel {
189    items: MenuItems,
190    page: Page,
191    notice: Option<UiNotice>,
192}
193
194impl UiModel {
195    pub const fn new(items: MenuItems) -> Self {
196        Self {
197            items,
198            page: Page::Menu(MenuItem::Status),
199            notice: None,
200        }
201    }
202
203    pub const fn page(&self) -> Page {
204        self.page
205    }
206
207    pub const fn notice(&self) -> Option<UiNotice> {
208        self.notice
209    }
210
211    pub const fn items(&self) -> MenuItems {
212        self.items
213    }
214
215    /// Whether the model is showing its home page with nothing pending.
216    ///
217    /// The attention lapse uses this to skip a pointless redraw.
218    pub const fn is_home(&self) -> bool {
219        matches!(self.page, Page::Menu(MenuItem::Status)) && self.notice.is_none()
220    }
221
222    /// Report a result and return to the status page.
223    pub fn set_notice(&mut self, notice: UiNotice) {
224        self.page = Page::Menu(MenuItem::Status);
225        self.notice = Some(notice);
226    }
227
228    pub fn clear_notice(&mut self) {
229        self.notice = None;
230    }
231
232    /// Drop everything transient and return to the home page.
233    ///
234    /// Called when display attention lapses, so the next press always
235    /// starts from a page whose meaning the user can see rather than
236    /// from a confirmation they walked away from.
237    pub fn go_home(&mut self) {
238        self.page = Page::Menu(MenuItem::Status);
239        self.notice = None;
240    }
241
242    /// Apply one resolved gesture.
243    ///
244    /// A destructive confirmation defaults to Cancel; Forward and
245    /// Backward both toggle its two choices, and Select activates the
246    /// visible one.
247    pub fn apply(&mut self, input: UiInput) -> Option<UiEffect> {
248        self.notice = None;
249        match (self.page, input) {
250            (Page::Menu(item), UiInput::Forward) => {
251                self.page = Page::Menu(self.items.step(item, 1));
252                None
253            }
254            (Page::Menu(item), UiInput::Backward) => {
255                self.page = Page::Menu(self.items.step(item, -1));
256                None
257            }
258            (Page::Menu(item), UiInput::Select) => {
259                if item.requires_confirmation() {
260                    self.page = Page::Confirm {
261                        item,
262                        confirm_selected: false,
263                    };
264                    return None;
265                }
266                let effect = item.effect();
267                if effect.is_some() {
268                    self.page = Page::Menu(MenuItem::Status);
269                }
270                effect
271            }
272            (
273                Page::Confirm {
274                    item,
275                    confirm_selected,
276                },
277                UiInput::Forward | UiInput::Backward,
278            ) => {
279                self.page = Page::Confirm {
280                    item,
281                    confirm_selected: !confirm_selected,
282                };
283                None
284            }
285            (
286                Page::Confirm {
287                    item,
288                    confirm_selected: false,
289                },
290                UiInput::Select,
291            ) => {
292                self.page = Page::Menu(item);
293                None
294            }
295            (
296                Page::Confirm {
297                    item,
298                    confirm_selected: true,
299                },
300                UiInput::Select,
301            ) => {
302                self.page = Page::Menu(MenuItem::Status);
303                item.effect()
304            }
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn full() -> UiModel {
314        UiModel::new(MenuItems::all())
315    }
316
317    #[test]
318    fn forward_and_backward_wrap_menu_items() {
319        let mut ui = full();
320        ui.apply(UiInput::Forward);
321        assert_eq!(ui.page(), Page::Menu(MenuItem::Stats));
322        ui.apply(UiInput::Forward);
323        assert_eq!(ui.page(), Page::Menu(MenuItem::CheckIn));
324        ui.apply(UiInput::Forward);
325        assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
326        ui.apply(UiInput::Forward);
327        assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
328        ui.apply(UiInput::Forward);
329        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
330        ui.apply(UiInput::Backward);
331        assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
332    }
333
334    #[test]
335    fn navigation_skips_items_the_board_does_not_enable() {
336        // A board with no bond storage to clear and no beacon.
337        let items = MenuItems::new().with(MenuItem::StartPairing);
338        let mut ui = UiModel::new(items);
339        ui.apply(UiInput::Forward);
340        assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
341        ui.apply(UiInput::Forward);
342        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
343        ui.apply(UiInput::Backward);
344        assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
345    }
346
347    #[test]
348    fn status_only_menu_stays_put() {
349        let mut ui = UiModel::new(MenuItems::new());
350        ui.apply(UiInput::Forward);
351        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
352        assert_eq!(ui.apply(UiInput::Select), None);
353    }
354
355    #[test]
356    fn safe_items_activate_without_confirmation() {
357        let mut ui = full();
358        ui.apply(UiInput::Forward);
359        ui.apply(UiInput::Forward);
360        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::CheckIn));
361        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
362
363        ui.apply(UiInput::Forward);
364        ui.apply(UiInput::Forward);
365        ui.apply(UiInput::Forward);
366        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::StartPairing));
367        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
368    }
369
370    #[test]
371    fn status_select_is_inert() {
372        let mut ui = full();
373        assert_eq!(ui.apply(UiInput::Select), None);
374        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
375    }
376
377    #[test]
378    fn clear_defaults_to_cancel_and_requires_visible_confirmation() {
379        let mut ui = full();
380        ui.apply(UiInput::Backward);
381        assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
382        assert_eq!(ui.apply(UiInput::Select), None);
383        assert_eq!(
384            ui.page(),
385            Page::Confirm {
386                item: MenuItem::ClearBonds,
387                confirm_selected: false,
388            }
389        );
390
391        // Selecting the default choice cancels, returning to the item.
392        assert_eq!(ui.apply(UiInput::Select), None);
393        assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
394
395        // Re-enter, visibly choose Clear, then confirm it.
396        ui.apply(UiInput::Select);
397        ui.apply(UiInput::Forward);
398        assert_eq!(
399            ui.page(),
400            Page::Confirm {
401                item: MenuItem::ClearBonds,
402                confirm_selected: true,
403            }
404        );
405        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::ClearBonds));
406        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
407    }
408
409    #[test]
410    fn backward_also_toggles_the_confirmation() {
411        let mut ui = full();
412        ui.apply(UiInput::Backward);
413        ui.apply(UiInput::Select);
414        ui.apply(UiInput::Backward);
415        assert_eq!(
416            ui.page(),
417            Page::Confirm {
418                item: MenuItem::ClearBonds,
419                confirm_selected: true,
420            }
421        );
422    }
423
424    #[test]
425    fn notice_returns_to_status_and_clears_on_input() {
426        let mut ui = full();
427        ui.apply(UiInput::Backward);
428        ui.set_notice(UiNotice::BondsCleared);
429        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
430        assert_eq!(ui.notice(), Some(UiNotice::BondsCleared));
431
432        ui.apply(UiInput::Forward);
433        assert_eq!(ui.notice(), None);
434        assert_eq!(ui.page(), Page::Menu(MenuItem::Stats));
435    }
436
437    #[test]
438    fn go_home_drops_a_pending_confirmation() {
439        let mut ui = full();
440        ui.apply(UiInput::Backward);
441        ui.apply(UiInput::Select);
442        assert!(!ui.is_home());
443
444        ui.go_home();
445        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
446        assert_eq!(ui.notice(), None);
447        assert!(ui.is_home());
448    }
449
450    #[test]
451    fn go_home_drops_a_stale_notice() {
452        let mut ui = full();
453        ui.set_notice(UiNotice::PairingStarted);
454        assert!(!ui.is_home());
455        ui.go_home();
456        assert!(ui.is_home());
457    }
458
459    #[test]
460    fn status_is_always_enabled() {
461        assert!(MenuItems::new().contains(MenuItem::Status));
462        assert!(MenuItems::all().contains(MenuItem::Status));
463        assert_eq!(MenuItems::new().len(), 1);
464        assert_eq!(MenuItems::all().len(), 5);
465    }
466}