umsh_ux_display_tracker/
menu.rs

1//! On-screen menu policy shared by every display tracker.
2//!
3//! The model is a tree three levels deep: a top level that reads, a
4//! [`Level::Settings`] list that groups the subsystems, and one list per
5//! subsystem holding the settings that change something. Each level is a
6//! wrapping list of [`MenuItem`]s with a confirmation page in front of
7//! the destructive entries. Each board enables the subset it can actually
8//! perform via [`MenuItems`]; navigation skips whatever is not enabled,
9//! so the same code drives a two-item Heltec menu and a full T-Echo tree
10//! without either firmware knowing the other exists.
11//!
12//! Depth is fixed and known here rather than discovered at runtime, so
13//! the cursor is one [`MenuItem`] and nothing else: an item knows its own
14//! [`level`](MenuItem::level), and a level knows the entry that opens it.
15//! That keeps [`UiModel`] `Copy`, which the display tasks rely on.
16//!
17//! The top level is a set of pages the user walks between: each of its
18//! three entries takes the whole panel while the cursor is on it. Every
19//! level below it is a list, and reading an entry there takes a Select,
20//! which opens a [`Page::Detail`]. Reading in place is a property of the
21//! level rather than of the entry, and the top level is the exception —
22//! a submenu that answered a question the moment the cursor crossed it
23//! would make walking the list a way of asking questions.
24//!
25//! [`MenuItem::Status`] is the home item and is always enabled: it is
26//! where boot starts, where an activated item returns to, and where the
27//! display-attention lapse sends the user back to (see
28//! [`crate::attention`]).
29
30/// One resolved navigation gesture.
31///
32/// Boards with a single button map click / double-click / hold-release
33/// onto the first three; boards with a D-pad map down / up / press onto
34/// the same three and have a real [`Back`](UiInput::Back) besides. The
35/// model never learns which.
36///
37/// [`Back`](UiInput::Back) leaves the current screen, where
38/// [`Backward`](UiInput::Backward) only moves the cursor within it. A
39/// board without a back button never sends it and reaches the same place
40/// through the Back entry every level carries; a board with one can also
41/// still use that entry, so the two never need to be told apart
42/// downstream.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum UiInput {
45    Forward,
46    Select,
47    Backward,
48    Back,
49}
50
51/// One list in the tree.
52///
53/// A level is a wrapping list of the [`MenuItem`]s that report it from
54/// [`MenuItem::level`]. Every level below the top opens from exactly one
55/// entry in its parent, which is what lets Back return without a stack.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum Level {
58    Top,
59    Settings,
60    Bluetooth,
61    Gnss,
62    Radio,
63}
64
65impl Level {
66    /// Every level, outermost first.
67    pub const ALL: [Level; 5] = [
68        Level::Top,
69        Level::Settings,
70        Level::Bluetooth,
71        Level::Gnss,
72        Level::Radio,
73    ];
74
75    /// The entry in the parent level that opens this one. `None` for the
76    /// top level, which nothing opens.
77    pub const fn opened_by(self) -> Option<MenuItem> {
78        match self {
79            Level::Top => None,
80            Level::Settings => Some(MenuItem::Settings),
81            Level::Bluetooth => Some(MenuItem::Bluetooth),
82            Level::Gnss => Some(MenuItem::Gnss),
83            Level::Radio => Some(MenuItem::Radio),
84        }
85    }
86
87    /// This level's own Back entry. `None` for the top level, which has
88    /// nowhere to go back to.
89    pub const fn back(self) -> Option<MenuItem> {
90        match self {
91            Level::Top => None,
92            Level::Settings => Some(MenuItem::SettingsBack),
93            Level::Bluetooth => Some(MenuItem::BluetoothBack),
94            Level::Gnss => Some(MenuItem::GnssBack),
95            Level::Radio => Some(MenuItem::RadioBack),
96        }
97    }
98}
99
100/// Which setting a [`EntryKind::Toggle`] entry flips.
101///
102/// The model never learns a toggle's value — that is device state the
103/// firmware owns and the renderer is handed separately. All the model
104/// does is say which one the user asked for.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum ToggleId {
107    Bluetooth,
108    Gnss,
109    ShareLocation,
110    Forwarding,
111}
112
113/// What an entry does when it is selected.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum EntryKind {
116    /// Has something to read. At the top level it reads in place, and
117    /// Select does nothing or the one extra action the entry defines —
118    /// home's check-in is the only one today. Below the top level it is
119    /// an ordinary list row, and Select opens its [`Page::Detail`].
120    Reading(Option<UiEffect>),
121    /// Opens the named level.
122    Submenu(Level),
123    /// Flips a setting and stays put, so the new state is on the screen
124    /// the user is already looking at.
125    Toggle(ToggleId),
126    /// Acts, returns home, and reports the outcome as a notice.
127    Action(UiEffect),
128    /// Acts only after a confirmation that defaults to Cancel.
129    Destructive(UiEffect),
130    /// Leaves this level for its parent.
131    Back,
132}
133
134/// An entry in the menu.
135///
136/// The enum is the union across all display trackers; a board narrows it
137/// with [`MenuItems`]. Declaration order is navigation order, and every
138/// level's entries are contiguous so stepping stays an index walk.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum MenuItem {
141    // ─── Top ───
142    /// Home. Shows the battery, and whatever else is not nominal.
143    Status,
144    /// This device's own address, for another device to take down.
145    Identity,
146    /// The settings that change something.
147    Settings,
148
149    // ─── Settings ───
150    SettingsBack,
151    /// The Bluetooth submenu.
152    Bluetooth,
153    /// The GNSS submenu.
154    Gnss,
155    /// The radio submenu.
156    Radio,
157
158    // ─── Bluetooth ───
159    BluetoothBack,
160    /// Turn the Bluetooth radio on and off.
161    BluetoothToggle,
162    /// Open a time-limited pairing window for a new companion.
163    StartPairing,
164    /// Forget every bonded companion. Confirmed before it runs.
165    ClearBonds,
166
167    // ─── GNSS ───
168    GnssBack,
169    /// Turn the GNSS receiver on and off.
170    GnssToggle,
171    /// Whether position goes out in what the device advertises. A
172    /// separate decision from whether the device knows where it is.
173    ShareLocation,
174
175    // ─── Radio ───
176    RadioBack,
177    /// Whether other nodes' frames are relayed onward.
178    Forwarding,
179    /// Radio activity since boot: frame counts, power, duty cycle. A
180    /// page, not an action.
181    Stats,
182}
183
184impl MenuItem {
185    /// Every item, in navigation order.
186    pub const ALL: [MenuItem; 17] = [
187        MenuItem::Status,
188        MenuItem::Identity,
189        MenuItem::Settings,
190        MenuItem::SettingsBack,
191        MenuItem::Bluetooth,
192        MenuItem::Gnss,
193        MenuItem::Radio,
194        MenuItem::BluetoothBack,
195        MenuItem::BluetoothToggle,
196        MenuItem::StartPairing,
197        MenuItem::ClearBonds,
198        MenuItem::GnssBack,
199        MenuItem::GnssToggle,
200        MenuItem::ShareLocation,
201        MenuItem::RadioBack,
202        MenuItem::Forwarding,
203        MenuItem::Stats,
204    ];
205
206    const fn bit(self) -> u32 {
207        1 << self as u32
208    }
209
210    const fn index(self) -> usize {
211        self as usize
212    }
213
214    /// Which list this entry belongs to.
215    pub const fn level(self) -> Level {
216        match self {
217            MenuItem::Status | MenuItem::Identity | MenuItem::Settings => Level::Top,
218            MenuItem::SettingsBack | MenuItem::Bluetooth | MenuItem::Gnss | MenuItem::Radio => {
219                Level::Settings
220            }
221            MenuItem::BluetoothBack
222            | MenuItem::BluetoothToggle
223            | MenuItem::StartPairing
224            | MenuItem::ClearBonds => Level::Bluetooth,
225            MenuItem::GnssBack | MenuItem::GnssToggle | MenuItem::ShareLocation => Level::Gnss,
226            MenuItem::RadioBack | MenuItem::Forwarding | MenuItem::Stats => Level::Radio,
227        }
228    }
229
230    /// What selecting this entry does.
231    pub const fn kind(self) -> EntryKind {
232        match self {
233            // Home's Select is the device's frequent, non-destructive
234            // action; the cost of firing it by accident is one frame of
235            // airtime.
236            MenuItem::Status => EntryKind::Reading(Some(UiEffect::CheckIn)),
237            MenuItem::Identity | MenuItem::Stats => EntryKind::Reading(None),
238            MenuItem::Settings => EntryKind::Submenu(Level::Settings),
239            MenuItem::Bluetooth => EntryKind::Submenu(Level::Bluetooth),
240            MenuItem::Gnss => EntryKind::Submenu(Level::Gnss),
241            MenuItem::Radio => EntryKind::Submenu(Level::Radio),
242            MenuItem::SettingsBack
243            | MenuItem::BluetoothBack
244            | MenuItem::GnssBack
245            | MenuItem::RadioBack => EntryKind::Back,
246            MenuItem::BluetoothToggle => EntryKind::Toggle(ToggleId::Bluetooth),
247            MenuItem::GnssToggle => EntryKind::Toggle(ToggleId::Gnss),
248            MenuItem::ShareLocation => EntryKind::Toggle(ToggleId::ShareLocation),
249            MenuItem::Forwarding => EntryKind::Toggle(ToggleId::Forwarding),
250            MenuItem::StartPairing => EntryKind::Action(UiEffect::StartPairing),
251            MenuItem::ClearBonds => EntryKind::Destructive(UiEffect::ClearBonds),
252        }
253    }
254
255    /// Entries a board never has to ask for.
256    ///
257    /// Home has to exist or there is nowhere to return to, and a Back
258    /// entry has to exist or a level the user entered has no exit — on a
259    /// one-button board the entry *is* the way out.
260    const fn always_enabled(self) -> bool {
261        matches!(
262            self,
263            MenuItem::Status
264                | MenuItem::SettingsBack
265                | MenuItem::BluetoothBack
266                | MenuItem::GnssBack
267                | MenuItem::RadioBack
268        )
269    }
270
271    /// Whether highlighting this entry is already enough to read it.
272    ///
273    /// A property of the *level*, not of the entry. The top level is a
274    /// set of pages the user walks between, so an entry there is the
275    /// whole panel while the cursor is on it; every level below it is a
276    /// list, where an entry is one row among its neighbors and reading it
277    /// takes a Select. The top level is the exception, and this is the
278    /// one place that says so.
279    pub const fn reads_in_place(self) -> bool {
280        matches!(self.level(), Level::Top)
281    }
282
283    /// Whether selecting this item opens a confirmation page rather than
284    /// acting immediately.
285    ///
286    /// Destructive items confirm; everything else is either harmless or
287    /// trivially reversible.
288    pub const fn requires_confirmation(self) -> bool {
289        matches!(self.kind(), EntryKind::Destructive(_))
290    }
291
292    /// What activating this item asks the firmware to do. Reading
293    /// entries, submenus, and Back are inert — they move the user
294    /// around rather than changing anything.
295    pub const fn effect(self) -> Option<UiEffect> {
296        match self.kind() {
297            EntryKind::Action(effect) | EntryKind::Destructive(effect) => Some(effect),
298            EntryKind::Reading(effect) => effect,
299            EntryKind::Toggle(id) => Some(UiEffect::Toggle(id)),
300            EntryKind::Submenu(_) | EntryKind::Back => None,
301        }
302    }
303}
304
305/// The set of menu items a board enables.
306///
307/// [`MenuItem::Status`] and every Back entry are always present
308/// regardless of how the set was built.
309#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310pub struct MenuItems(u32);
311
312impl MenuItems {
313    /// Just the entries no board can do without.
314    pub const fn new() -> Self {
315        Self(
316            MenuItem::Status.bit()
317                | MenuItem::SettingsBack.bit()
318                | MenuItem::BluetoothBack.bit()
319                | MenuItem::GnssBack.bit()
320                | MenuItem::RadioBack.bit(),
321        )
322    }
323
324    /// Every item this crate defines.
325    pub const fn all() -> Self {
326        let mut bits = 0u32;
327        let mut i = 0;
328        while i < MenuItem::ALL.len() {
329            bits |= MenuItem::ALL[i].bit();
330            i += 1;
331        }
332        Self(bits)
333    }
334
335    /// Enable one more item.
336    pub const fn with(self, item: MenuItem) -> Self {
337        Self(self.0 | item.bit())
338    }
339
340    /// Disable one item.
341    ///
342    /// The counterpart to [`with`](Self::with) for boards that start from
343    /// [`all`](Self::all) and name what they cannot do, which is the
344    /// shorter list on most hardware. Removing every entry of a level
345    /// removes the way into it too — see
346    /// [`level_is_empty`](Self::level_is_empty) — so a board need not
347    /// also remember to disable the submenu that led there.
348    pub const fn without(self, item: MenuItem) -> Self {
349        Self(self.0 & !item.bit())
350    }
351
352    pub const fn contains(self, item: MenuItem) -> bool {
353        item.always_enabled() || self.0 & item.bit() != 0
354    }
355
356    /// Number of enabled items across the whole tree. Always at least
357    /// one.
358    pub const fn len(self) -> u32 {
359        self.0.count_ones()
360    }
361
362    pub const fn is_empty(self) -> bool {
363        false
364    }
365
366    /// Whether a level has anything worth entering: any enabled entry
367    /// that is not its own Back.
368    ///
369    /// A submenu whose entries are all disabled is not shown at all,
370    /// rather than opening onto a list containing only Back.
371    pub fn level_is_empty(self, level: Level) -> bool {
372        !MenuItem::ALL
373            .iter()
374            .any(|&item| item.level() == level && !item.is_back() && self.reachable(item))
375    }
376
377    /// Whether an entry can be navigated to: enabled, and not a doorway
378    /// into a level with nothing in it.
379    fn reachable(self, item: MenuItem) -> bool {
380        if !self.contains(item) {
381            return false;
382        }
383        match item.kind() {
384            EntryKind::Submenu(level) => !self.level_is_empty(level),
385            _ => true,
386        }
387    }
388
389    /// The enabled entries of one level, in navigation order.
390    ///
391    /// This is what a renderer draws a list from, so it and
392    /// [`step`](Self::step) must agree about what is on screen.
393    pub fn entries(self, level: Level) -> impl Iterator<Item = MenuItem> {
394        MenuItem::ALL
395            .into_iter()
396            .filter(move |&item| item.level() == level && self.reachable(item))
397    }
398
399    /// The entry a freshly entered level should highlight: the first one
400    /// after Back.
401    ///
402    /// Highlighting the exit of a screen the user just asked to enter
403    /// would waste the press that got them there. A level with nothing
404    /// but Back falls back to it, though [`level_is_empty`](Self::level_is_empty)
405    /// means such a level is never entered.
406    pub fn first_after_back(self, level: Level) -> MenuItem {
407        MenuItem::ALL
408            .iter()
409            .copied()
410            .find(|&item| item.level() == level && !item.is_back() && self.reachable(item))
411            .or_else(|| level.back())
412            .unwrap_or(MenuItem::Status)
413    }
414
415    /// Step `from` by `step` positions through the enabled items of its
416    /// own level, wrapping. `step` is +1 for forward and -1 for
417    /// backward.
418    fn step(self, from: MenuItem, step: isize) -> MenuItem {
419        let level = from.level();
420        let n = MenuItem::ALL.len();
421        let mut index = from.index();
422        // At worst this visits every item once. Every level holds at
423        // least one always-enabled entry — Status at the top, Back
424        // below it — so it always terminates on something.
425        for _ in 0..n {
426            index = (index as isize + step).rem_euclid(n as isize) as usize;
427            let candidate = MenuItem::ALL[index];
428            if candidate.level() == level && self.reachable(candidate) {
429                return candidate;
430            }
431        }
432        from
433    }
434}
435
436impl MenuItem {
437    const fn is_back(self) -> bool {
438        matches!(self.kind(), EntryKind::Back)
439    }
440}
441
442impl Default for MenuItems {
443    fn default() -> Self {
444        Self::new()
445    }
446}
447
448/// The screen currently being shown.
449#[derive(Clone, Copy, Debug, PartialEq, Eq)]
450pub enum Page {
451    /// The menu, with `.0` highlighted. The item's own
452    /// [`level`](MenuItem::level) is the list being shown.
453    Menu(MenuItem),
454    /// The page a reading entry below the top level opens, taking the
455    /// whole panel. The entry it was opened from is where any press
456    /// returns to.
457    Detail(MenuItem),
458    /// A confirmation for a destructive `item`. `confirm_selected` is
459    /// false while Cancel is the visible choice.
460    Confirm {
461        item: MenuItem,
462        confirm_selected: bool,
463    },
464}
465
466/// Something the firmware should do as a result of a selection.
467#[derive(Clone, Copy, Debug, PartialEq, Eq)]
468pub enum UiEffect {
469    CheckIn,
470    StartPairing,
471    ClearBonds,
472    /// Flip a setting. The firmware applies it and publishes the new
473    /// value; the menu does not track it.
474    Toggle(ToggleId),
475}
476
477/// A transient result message shown on the status page.
478#[derive(Clone, Copy, Debug, PartialEq, Eq)]
479pub enum UiNotice {
480    CheckInRequested,
481    PairingStarted,
482    PairingUnavailable,
483    BondsCleared,
484    ClearFailed,
485    /// A toggle the board could not carry out.
486    ToggleUnavailable,
487}
488
489/// The menu state machine.
490///
491/// Pure logic: no clock, no I/O. The owning task feeds it resolved
492/// gestures and renders whatever [`page`](Self::page) and
493/// [`notice`](Self::notice) report.
494#[derive(Clone, Copy, Debug, PartialEq, Eq)]
495pub struct UiModel {
496    items: MenuItems,
497    page: Page,
498    notice: Option<UiNotice>,
499}
500
501impl UiModel {
502    pub const fn new(items: MenuItems) -> Self {
503        Self {
504            items,
505            page: Page::Menu(MenuItem::Status),
506            notice: None,
507        }
508    }
509
510    pub const fn page(&self) -> Page {
511        self.page
512    }
513
514    pub const fn notice(&self) -> Option<UiNotice> {
515        self.notice
516    }
517
518    pub const fn items(&self) -> MenuItems {
519        self.items
520    }
521
522    /// The list currently on screen.
523    pub const fn level(&self) -> Level {
524        match self.page {
525            Page::Menu(item) | Page::Detail(item) => item.level(),
526            Page::Confirm { item, .. } => item.level(),
527        }
528    }
529
530    /// Whether the model is showing its home page with nothing pending.
531    ///
532    /// The attention lapse uses this to skip a pointless redraw, so it
533    /// must be false anywhere below the top level — a bistable panel
534    /// that skips the refresh keeps showing a submenu the user walked
535    /// away from.
536    pub const fn is_home(&self) -> bool {
537        matches!(self.page, Page::Menu(MenuItem::Status)) && self.notice.is_none()
538    }
539
540    /// Report a result and return to the status page.
541    pub fn set_notice(&mut self, notice: UiNotice) {
542        self.page = Page::Menu(MenuItem::Status);
543        self.notice = Some(notice);
544    }
545
546    pub fn clear_notice(&mut self) {
547        self.notice = None;
548    }
549
550    /// Drop everything transient and return to the home page.
551    ///
552    /// Called when display attention lapses, so the next press always
553    /// starts from a page whose meaning the user can see rather than
554    /// from a confirmation they walked away from — or from a settings
555    /// list three levels down.
556    pub fn go_home(&mut self) {
557        self.page = Page::Menu(MenuItem::Status);
558        self.notice = None;
559    }
560
561    /// Apply one resolved gesture.
562    ///
563    /// A destructive confirmation defaults to Cancel; Forward and
564    /// Backward both toggle its two choices, and Select activates the
565    /// visible one.
566    /// Leave `level` for the entry that opened it.
567    ///
568    /// The entry that opened a level is what the user is returning to, so
569    /// the way back in is under the cursor rather than a list-length
570    /// away. Leaving the top level — which nothing opened — goes home
571    /// instead, so Back is never a press that does nothing.
572    fn leave(&mut self, level: Level) {
573        self.page = Page::Menu(level.opened_by().unwrap_or(MenuItem::Status));
574    }
575
576    pub fn apply(&mut self, input: UiInput) -> Option<UiEffect> {
577        self.notice = None;
578        match (self.page, input) {
579            (Page::Menu(item), UiInput::Forward) => {
580                self.page = Page::Menu(self.items.step(item, 1));
581                None
582            }
583            (Page::Menu(item), UiInput::Backward) => {
584                self.page = Page::Menu(self.items.step(item, -1));
585                None
586            }
587            (Page::Menu(item), UiInput::Select) => match item.kind() {
588                // At the top level the entry is already the whole screen,
589                // so it stays where it is and Select is free to carry its
590                // action — home's check-in is the only one.
591                EntryKind::Reading(effect) if item.reads_in_place() => effect,
592                // Below the top the entry is one row of a list, so Select
593                // is what opens it. Reaching a page by walking past it
594                // would make walking the list a way of asking questions.
595                EntryKind::Reading(_) => {
596                    self.page = Page::Detail(item);
597                    None
598                }
599                EntryKind::Submenu(level) => {
600                    self.page = Page::Menu(self.items.first_after_back(level));
601                    None
602                }
603                EntryKind::Back => {
604                    self.leave(item.level());
605                    None
606                }
607                // A toggle stays put: its whole result is a state the
608                // user is looking at, and returning home would hide the
609                // evidence that the press worked.
610                EntryKind::Toggle(id) => Some(UiEffect::Toggle(id)),
611                EntryKind::Action(effect) => {
612                    self.page = Page::Menu(MenuItem::Status);
613                    Some(effect)
614                }
615                EntryKind::Destructive(_) => {
616                    self.page = Page::Confirm {
617                        item,
618                        confirm_selected: false,
619                    };
620                    None
621                }
622            },
623            (Page::Menu(item), UiInput::Back) => {
624                self.leave(item.level());
625                None
626            }
627            // A reading page has nothing to walk and nothing to activate,
628            // so any press dismisses it back onto the entry it was opened
629            // from. A one-button board has no fourth gesture to reserve
630            // for a page whose only remaining question is "done?".
631            (Page::Detail(item), _) => {
632                self.page = Page::Menu(item);
633                None
634            }
635            // Backing out of a question is answering it with no, which
636            // is the answer a confirmation defaults to anyway.
637            (Page::Confirm { item, .. }, UiInput::Back) => {
638                self.page = Page::Menu(item);
639                None
640            }
641            (
642                Page::Confirm {
643                    item,
644                    confirm_selected,
645                },
646                UiInput::Forward | UiInput::Backward,
647            ) => {
648                self.page = Page::Confirm {
649                    item,
650                    confirm_selected: !confirm_selected,
651                };
652                None
653            }
654            (
655                Page::Confirm {
656                    item,
657                    confirm_selected: false,
658                },
659                UiInput::Select,
660            ) => {
661                self.page = Page::Menu(item);
662                None
663            }
664            (
665                Page::Confirm {
666                    item,
667                    confirm_selected: true,
668                },
669                UiInput::Select,
670            ) => {
671                self.page = Page::Menu(MenuItem::Status);
672                item.effect()
673            }
674        }
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    fn full() -> UiModel {
683        UiModel::new(MenuItems::all())
684    }
685
686    /// Walk to `item` from wherever the model is, by Forward presses
687    /// within one level. Panics rather than looping forever.
688    fn walk_to(ui: &mut UiModel, item: MenuItem) {
689        for _ in 0..MenuItem::ALL.len() + 1 {
690            if ui.page() == Page::Menu(item) {
691                return;
692            }
693            ui.apply(UiInput::Forward);
694        }
695        panic!("never reached {item:?}");
696    }
697
698    #[test]
699    fn forward_and_backward_wrap_the_top_level() {
700        let mut ui = full();
701        ui.apply(UiInput::Forward);
702        assert_eq!(ui.page(), Page::Menu(MenuItem::Identity));
703        ui.apply(UiInput::Forward);
704        assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
705        ui.apply(UiInput::Forward);
706        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
707        ui.apply(UiInput::Backward);
708        assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
709    }
710
711    #[test]
712    fn navigation_never_leaves_the_current_level() {
713        let mut ui = full();
714        walk_to(&mut ui, MenuItem::Settings);
715        ui.apply(UiInput::Select);
716        // Every entry reachable by walking is a Settings entry.
717        for _ in 0..8 {
718            let Page::Menu(item) = ui.page() else {
719                panic!("left the menu");
720            };
721            assert_eq!(item.level(), Level::Settings);
722            ui.apply(UiInput::Forward);
723        }
724    }
725
726    #[test]
727    fn entering_a_submenu_highlights_the_entry_after_back() {
728        let mut ui = full();
729        walk_to(&mut ui, MenuItem::Settings);
730        assert_eq!(ui.apply(UiInput::Select), None);
731        assert_eq!(ui.page(), Page::Menu(MenuItem::Bluetooth));
732        assert_eq!(ui.level(), Level::Settings);
733
734        // One Previous reaches the way out.
735        ui.apply(UiInput::Backward);
736        assert_eq!(ui.page(), Page::Menu(MenuItem::SettingsBack));
737    }
738
739    #[test]
740    fn back_returns_to_the_entry_that_opened_the_level() {
741        let mut ui = full();
742        walk_to(&mut ui, MenuItem::Settings);
743        ui.apply(UiInput::Select);
744        walk_to(&mut ui, MenuItem::Gnss);
745        ui.apply(UiInput::Select);
746        assert_eq!(ui.page(), Page::Menu(MenuItem::GnssToggle));
747
748        walk_to(&mut ui, MenuItem::GnssBack);
749        assert_eq!(ui.apply(UiInput::Select), None);
750        assert_eq!(ui.page(), Page::Menu(MenuItem::Gnss));
751
752        walk_to(&mut ui, MenuItem::SettingsBack);
753        assert_eq!(ui.apply(UiInput::Select), None);
754        assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
755        assert_eq!(ui.level(), Level::Top);
756    }
757
758    /// A board with a back button reaches the same places without ever
759    /// walking to the Back entry.
760    #[test]
761    fn a_back_press_leaves_the_level_from_any_entry() {
762        let mut ui = full();
763        walk_to(&mut ui, MenuItem::Settings);
764        ui.apply(UiInput::Select);
765        walk_to(&mut ui, MenuItem::Bluetooth);
766        ui.apply(UiInput::Select);
767        walk_to(&mut ui, MenuItem::ClearBonds);
768
769        assert_eq!(ui.apply(UiInput::Back), None);
770        assert_eq!(ui.page(), Page::Menu(MenuItem::Bluetooth));
771        assert_eq!(ui.apply(UiInput::Back), None);
772        assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
773        assert_eq!(ui.level(), Level::Top);
774    }
775
776    /// Back at the top level is still a press that does something.
777    #[test]
778    fn a_back_press_at_the_top_goes_home() {
779        let mut ui = full();
780        walk_to(&mut ui, MenuItem::Identity);
781        assert_eq!(ui.apply(UiInput::Back), None);
782        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
783        // And from home it is a no-op rather than a wrap into the tree.
784        assert_eq!(ui.apply(UiInput::Back), None);
785        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
786    }
787
788    #[test]
789    fn a_back_press_answers_a_confirmation_with_no() {
790        let mut ui = full();
791        walk_to(&mut ui, MenuItem::Settings);
792        ui.apply(UiInput::Select);
793        walk_to(&mut ui, MenuItem::Bluetooth);
794        ui.apply(UiInput::Select);
795        walk_to(&mut ui, MenuItem::ClearBonds);
796        ui.apply(UiInput::Select);
797        // Even with the destructive choice under the cursor.
798        ui.apply(UiInput::Forward);
799        assert_eq!(
800            ui.page(),
801            Page::Confirm {
802                item: MenuItem::ClearBonds,
803                confirm_selected: true,
804            }
805        );
806        assert_eq!(ui.apply(UiInput::Back), None);
807        assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
808    }
809
810    #[test]
811    fn a_submenu_with_nothing_in_it_is_not_shown() {
812        // A board with no GNSS and no bond storage: Bluetooth keeps its
813        // pairing entry, GNSS has nothing at all.
814        let items = MenuItems::new()
815            .with(MenuItem::Settings)
816            .with(MenuItem::Bluetooth)
817            .with(MenuItem::StartPairing)
818            .with(MenuItem::Gnss);
819        assert!(items.level_is_empty(Level::Gnss));
820        assert!(!items.level_is_empty(Level::Bluetooth));
821
822        let mut ui = UiModel::new(items);
823        walk_to(&mut ui, MenuItem::Settings);
824        ui.apply(UiInput::Select);
825        // Bluetooth is reachable, GNSS is skipped even though its own
826        // entry was enabled.
827        for _ in 0..6 {
828            assert_ne!(ui.page(), Page::Menu(MenuItem::Gnss));
829            ui.apply(UiInput::Forward);
830        }
831    }
832
833    #[test]
834    fn a_toggle_stays_on_its_entry() {
835        let mut ui = full();
836        walk_to(&mut ui, MenuItem::Settings);
837        ui.apply(UiInput::Select);
838        walk_to(&mut ui, MenuItem::Radio);
839        ui.apply(UiInput::Select);
840        walk_to(&mut ui, MenuItem::Forwarding);
841
842        assert_eq!(
843            ui.apply(UiInput::Select),
844            Some(UiEffect::Toggle(ToggleId::Forwarding))
845        );
846        assert_eq!(ui.page(), Page::Menu(MenuItem::Forwarding));
847    }
848
849    #[test]
850    fn navigation_skips_items_the_board_does_not_enable() {
851        // A board with no bond storage to clear.
852        let items = MenuItems::new()
853            .with(MenuItem::Bluetooth)
854            .with(MenuItem::BluetoothToggle)
855            .with(MenuItem::StartPairing);
856        let mut ui = UiModel::new(items);
857        ui.page = Page::Menu(MenuItem::BluetoothToggle);
858        ui.apply(UiInput::Forward);
859        assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
860        ui.apply(UiInput::Forward);
861        assert_eq!(ui.page(), Page::Menu(MenuItem::BluetoothBack));
862        ui.apply(UiInput::Backward);
863        assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
864    }
865
866    #[test]
867    fn status_only_menu_stays_put() {
868        let mut ui = UiModel::new(MenuItems::new());
869        ui.apply(UiInput::Forward);
870        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
871        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::CheckIn));
872        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
873    }
874
875    #[test]
876    fn safe_items_activate_and_return_home() {
877        let mut ui = full();
878        walk_to(&mut ui, MenuItem::Settings);
879        ui.apply(UiInput::Select);
880        walk_to(&mut ui, MenuItem::Bluetooth);
881        ui.apply(UiInput::Select);
882        walk_to(&mut ui, MenuItem::StartPairing);
883        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::StartPairing));
884        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
885        assert_eq!(ui.level(), Level::Top);
886    }
887
888    #[test]
889    fn top_level_reading_entries_stay_put_and_only_home_acts() {
890        let mut ui = full();
891        // Home carries the device's frequent, non-destructive action.
892        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::CheckIn));
893        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
894
895        walk_to(&mut ui, MenuItem::Identity);
896        assert_eq!(ui.apply(UiInput::Select), None);
897        assert_eq!(ui.page(), Page::Menu(MenuItem::Identity));
898    }
899
900    /// Walk to Statistics, which lives two levels down.
901    fn at_stats() -> UiModel {
902        let mut ui = full();
903        walk_to(&mut ui, MenuItem::Settings);
904        ui.apply(UiInput::Select);
905        walk_to(&mut ui, MenuItem::Radio);
906        ui.apply(UiInput::Select);
907        walk_to(&mut ui, MenuItem::Stats);
908        ui
909    }
910
911    #[test]
912    fn a_reading_entry_below_the_top_opens_a_page() {
913        let mut ui = at_stats();
914        // Walking onto it shows the Radio list, not the statistics.
915        assert_eq!(ui.page(), Page::Menu(MenuItem::Stats));
916        assert_eq!(ui.apply(UiInput::Select), None);
917        assert_eq!(ui.page(), Page::Detail(MenuItem::Stats));
918        assert_eq!(ui.level(), Level::Radio);
919    }
920
921    #[test]
922    fn any_press_dismisses_a_reading_page() {
923        for input in [
924            UiInput::Forward,
925            UiInput::Backward,
926            UiInput::Select,
927            UiInput::Back,
928        ] {
929            let mut ui = at_stats();
930            ui.apply(UiInput::Select);
931            assert_eq!(ui.page(), Page::Detail(MenuItem::Stats));
932            // Back onto the entry it was opened from, not to the top of
933            // the list and not home.
934            assert_eq!(ui.apply(input), None, "{input:?}");
935            assert_eq!(ui.page(), Page::Menu(MenuItem::Stats), "{input:?}");
936        }
937    }
938
939    #[test]
940    fn a_reading_page_is_never_home_and_a_lapse_unwinds_it() {
941        let mut ui = at_stats();
942        ui.apply(UiInput::Select);
943        assert!(!ui.is_home());
944        ui.go_home();
945        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
946        assert_eq!(ui.level(), Level::Top);
947        assert!(ui.is_home());
948    }
949
950    /// Walk to Clear bonds, which lives two levels down.
951    fn at_clear_bonds() -> UiModel {
952        let mut ui = full();
953        walk_to(&mut ui, MenuItem::Settings);
954        ui.apply(UiInput::Select);
955        walk_to(&mut ui, MenuItem::Bluetooth);
956        ui.apply(UiInput::Select);
957        walk_to(&mut ui, MenuItem::ClearBonds);
958        ui
959    }
960
961    #[test]
962    fn clear_defaults_to_cancel_and_requires_visible_confirmation() {
963        let mut ui = at_clear_bonds();
964        assert_eq!(ui.apply(UiInput::Select), None);
965        assert_eq!(
966            ui.page(),
967            Page::Confirm {
968                item: MenuItem::ClearBonds,
969                confirm_selected: false,
970            }
971        );
972
973        // Selecting the default choice cancels, returning to the item.
974        assert_eq!(ui.apply(UiInput::Select), None);
975        assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
976
977        // Re-enter, visibly choose Clear, then confirm it.
978        ui.apply(UiInput::Select);
979        ui.apply(UiInput::Forward);
980        assert_eq!(
981            ui.page(),
982            Page::Confirm {
983                item: MenuItem::ClearBonds,
984                confirm_selected: true,
985            }
986        );
987        assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::ClearBonds));
988        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
989    }
990
991    #[test]
992    fn backward_also_toggles_the_confirmation() {
993        let mut ui = at_clear_bonds();
994        ui.apply(UiInput::Select);
995        ui.apply(UiInput::Backward);
996        assert_eq!(
997            ui.page(),
998            Page::Confirm {
999                item: MenuItem::ClearBonds,
1000                confirm_selected: true,
1001            }
1002        );
1003    }
1004
1005    #[test]
1006    fn notice_returns_to_status_and_clears_on_input() {
1007        let mut ui = full();
1008        walk_to(&mut ui, MenuItem::Settings);
1009        ui.set_notice(UiNotice::BondsCleared);
1010        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
1011        assert_eq!(ui.notice(), Some(UiNotice::BondsCleared));
1012
1013        ui.apply(UiInput::Forward);
1014        assert_eq!(ui.notice(), None);
1015        assert_eq!(ui.page(), Page::Menu(MenuItem::Identity));
1016    }
1017
1018    #[test]
1019    fn go_home_unwinds_from_the_deepest_level() {
1020        let mut ui = at_clear_bonds();
1021        ui.apply(UiInput::Select);
1022        assert!(!ui.is_home());
1023
1024        ui.go_home();
1025        assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
1026        assert_eq!(ui.notice(), None);
1027        assert_eq!(ui.level(), Level::Top);
1028        assert!(ui.is_home());
1029    }
1030
1031    #[test]
1032    fn a_submenu_is_never_home() {
1033        let mut ui = full();
1034        walk_to(&mut ui, MenuItem::Settings);
1035        ui.apply(UiInput::Select);
1036        assert!(!ui.is_home());
1037    }
1038
1039    #[test]
1040    fn go_home_drops_a_stale_notice() {
1041        let mut ui = full();
1042        ui.set_notice(UiNotice::PairingStarted);
1043        assert!(!ui.is_home());
1044        ui.go_home();
1045        assert!(ui.is_home());
1046    }
1047
1048    #[test]
1049    fn home_and_every_exit_are_always_enabled() {
1050        let bare = MenuItems::new();
1051        assert!(bare.contains(MenuItem::Status));
1052        for level in Level::ALL {
1053            if let Some(back) = level.back() {
1054                assert!(bare.contains(back), "{level:?} has no way out");
1055            }
1056        }
1057    }
1058
1059    #[test]
1060    fn every_level_below_the_top_has_a_back_and_an_opener() {
1061        for level in Level::ALL {
1062            let has_back = MenuItem::ALL
1063                .iter()
1064                .any(|i| i.level() == level && i.is_back());
1065            assert_eq!(has_back, level != Level::Top, "{level:?}");
1066            assert_eq!(
1067                level.opened_by().is_some(),
1068                level != Level::Top,
1069                "{level:?}"
1070            );
1071            assert_eq!(level.back().is_some(), level != Level::Top, "{level:?}");
1072        }
1073    }
1074
1075    #[test]
1076    fn a_levels_opener_and_back_agree_about_where_they_sit() {
1077        for level in Level::ALL {
1078            if let (Some(opener), Some(back)) = (level.opened_by(), level.back()) {
1079                // The opener lives in the parent; Back lives in the level
1080                // it leaves.
1081                assert_eq!(opener.kind(), EntryKind::Submenu(level));
1082                assert_eq!(back.level(), level);
1083            }
1084        }
1085    }
1086
1087    #[test]
1088    fn every_item_is_listed_exactly_once() {
1089        for item in MenuItem::ALL {
1090            let count = MenuItem::ALL.iter().filter(|&&i| i == item).count();
1091            assert_eq!(count, 1, "{item:?}");
1092        }
1093        assert_eq!(MenuItems::all().len(), MenuItem::ALL.len() as u32);
1094    }
1095
1096    #[test]
1097    fn each_levels_entries_are_contiguous() {
1098        // `step` walks the flat index, so a level whose entries are
1099        // interleaved with another's would wrap through the wrong list.
1100        for level in Level::ALL {
1101            let mut first = None;
1102            let mut offset = 0;
1103            for (position, item) in MenuItem::ALL.iter().enumerate() {
1104                if item.level() != level {
1105                    continue;
1106                }
1107                let start = *first.get_or_insert(position);
1108                assert_eq!(position, start + offset, "{level:?} is not contiguous");
1109                offset += 1;
1110            }
1111            assert!(first.is_some(), "{level:?} has no entries");
1112        }
1113    }
1114}