Skip to main content

retroglyph_ui/state/
menu_bar.rs

1use crate::state::MenuState;
2
3/// Which label's popup is open (if any) for a [`MenuBar`](crate::widget::MenuBar), plus that
4/// popup's own [`MenuState`].
5///
6/// A menu bar has at most one open popup at a time, so this wraps a single [`MenuState`] rather
7/// than one per label: switching which label is open always goes through [`open`](Self::open),
8/// which closes whatever was open first. Holds no reference to the bar's own titles/rows, the
9/// same design [`MenuState`] itself uses: every method that needs to know a label's row count
10/// takes `len` and a `selectable` predicate rather than a concrete row type, so this stays
11/// reusable across whatever row type a call site's [`MenuBar`](crate::widget::MenuBar) uses.
12///
13/// [`open_label`](Self::open_label) is gated on [`MenuState::is_open`] rather than tracked as an
14/// independent flag: [`MenuState::handle_event`] (Escape) and
15/// [`Menu::show`](crate::widget::Menu::show) (an outside click, or a row activation) already
16/// close the inner [`MenuState`] on the caller's behalf, and gating the getter this way means
17/// those paths automatically clear which label reads as open too, with nothing to keep in sync
18/// by hand.
19///
20/// # Examples
21///
22/// ```
23/// use retroglyph_ui::state::MenuBarState;
24///
25/// // Two labels, each with one selectable row.
26/// let selectable = |_: usize| true;
27///
28/// let mut state = MenuBarState::new();
29/// state.open(0, 1, selectable);
30/// assert_eq!(state.open_label(), Some(0));
31///
32/// // Hovering another label while one is open switches to it (see `MenuBar::show`); modeled
33/// // here directly as another `open` call.
34/// state.open(1, 1, selectable);
35/// assert_eq!(state.open_label(), Some(1));
36///
37/// state.close();
38/// assert_eq!(state.open_label(), None);
39/// ```
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub struct MenuBarState {
42    open: usize,
43    menu: MenuState,
44    /// The label [`MenuBar::show`](crate::widget::MenuBar::show) resolved the pointer over as of
45    /// the *last* frame it ran, so it can tell a genuinely new hover (this frame's resolved
46    /// label differs from this) from a pointer that's simply still sitting over the same label
47    /// it was last frame. Without this, re-checking "is the pointer over a different label than
48    /// the one that's open" fresh every frame would immediately undo a keyboard-driven
49    /// [`MenuBar::handle_event`](crate::widget::MenuBar::handle_event) switch the instant the
50    /// pointer happens to still be resting over whatever label was open *before* that keypress,
51    /// since nothing else would tell the difference between that and a pointer that had just
52    /// freshly moved there.
53    hover: Option<usize>,
54}
55
56impl MenuBarState {
57    /// A closed menu bar: no label open.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self {
61            open: 0,
62            menu: MenuState::new(),
63            hover: None,
64        }
65    }
66
67    /// The label whose popup is currently open, if any. See the type docs for why this is
68    /// derived from [`MenuState::is_open`] rather than stored independently.
69    #[must_use]
70    pub const fn open_label(&self) -> Option<usize> {
71        if self.menu.is_open() {
72            Some(self.open)
73        } else {
74            None
75        }
76    }
77
78    /// The open popup's own state, for driving it through
79    /// [`Menu::show`](crate::widget::Menu::show).
80    #[must_use]
81    pub const fn menu(&self) -> &MenuState {
82        &self.menu
83    }
84
85    /// Mutable access to the open popup's own state, for driving it through
86    /// [`Menu::show`](crate::widget::Menu::show).
87    pub const fn menu_mut(&mut self) -> &mut MenuState {
88        &mut self.menu
89    }
90
91    /// Open `label`'s popup, selecting its first selectable row among `0..len` (skipping any
92    /// leading separators/labels), closing whatever label's popup was open first. A no-op
93    /// (closes without opening the new label) if `label` has no selectable row at all, mirroring
94    /// [`MenuState::open`].
95    pub fn open(&mut self, label: usize, len: usize, selectable: impl Fn(usize) -> bool) {
96        self.menu.close();
97        self.menu.open(len, selectable);
98        self.open = label;
99    }
100
101    /// Close whichever label's popup is open. A no-op if none is.
102    pub const fn close(&mut self) {
103        self.menu.close();
104    }
105
106    /// [`close`](Self::close) if `label`'s popup is already open, otherwise
107    /// [`open`](Self::open) it.
108    pub fn toggle(&mut self, label: usize, len: usize, selectable: impl Fn(usize) -> bool) {
109        if self.open_label() == Some(label) {
110            self.close();
111        } else {
112            self.open(label, len, selectable);
113        }
114    }
115
116    /// Switch to `label`'s popup, but only on the frame `label` first differs from what
117    /// [`hover`](Self::hover) resolved to last time this was called -- a no-op both while a
118    /// pointer sits still over the same label across frames, and while no popup is open at all
119    /// (a cold hover; see [`MenuBar`](crate::widget::MenuBar)'s docs). See the `hover` field's
120    /// own doc comment for why this edge-triggering, rather than a fresh "is `label` different
121    /// from what's open" check every frame, is what keeps this from fighting a
122    /// [`MenuBar::handle_event`](crate::widget::MenuBar::handle_event) keyboard switch.
123    pub fn hover(&mut self, label: Option<usize>, len: usize, selectable: impl Fn(usize) -> bool) {
124        if label != self.hover {
125            if let Some(label) = label
126                && self.open_label().is_some_and(|open| open != label)
127            {
128                self.open(label, len, selectable);
129            }
130            self.hover = label;
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn selectable_at(indices: &'static [usize]) -> impl Fn(usize) -> bool {
140        move |i| indices.contains(&i)
141    }
142
143    #[test]
144    fn starts_with_nothing_open() {
145        let state = MenuBarState::new();
146        assert_eq!(state.open_label(), None);
147        assert!(!state.menu().is_open());
148    }
149
150    #[test]
151    fn open_selects_the_first_selectable_row() {
152        let mut state = MenuBarState::new();
153        state.open(1, 3, selectable_at(&[1, 2]));
154        assert_eq!(state.open_label(), Some(1));
155        assert_eq!(state.menu().selected(), Some(1));
156    }
157
158    #[test]
159    fn opening_a_different_label_closes_the_first() {
160        let mut state = MenuBarState::new();
161        state.open(0, 2, selectable_at(&[0, 1]));
162        state.open(1, 2, selectable_at(&[0, 1]));
163        assert_eq!(state.open_label(), Some(1));
164        // Selection reset for the newly opened label, not carried over from label 0.
165        assert_eq!(state.menu().selected(), Some(0));
166    }
167
168    #[test]
169    fn a_label_with_no_selectable_row_never_opens() {
170        let mut state = MenuBarState::new();
171        state.open(0, 2, selectable_at(&[]));
172        assert_eq!(state.open_label(), None);
173    }
174
175    #[test]
176    fn close_clears_open_label() {
177        let mut state = MenuBarState::new();
178        state.open(0, 1, selectable_at(&[0]));
179        state.close();
180        assert_eq!(state.open_label(), None);
181        assert!(!state.menu().is_open());
182    }
183
184    #[test]
185    fn toggle_closes_the_same_label_but_opens_a_different_one() {
186        let mut state = MenuBarState::new();
187        state.toggle(0, 1, selectable_at(&[0]));
188        assert_eq!(state.open_label(), Some(0));
189        state.toggle(0, 1, selectable_at(&[0]));
190        assert_eq!(state.open_label(), None);
191
192        state.toggle(0, 1, selectable_at(&[0]));
193        state.toggle(1, 1, selectable_at(&[0]));
194        assert_eq!(state.open_label(), Some(1));
195    }
196
197    /// `MenuState::close` (as `MenuState::handle_event`'s Escape path, or
198    /// `Menu::show`'s outside-click/activation path, would call on `menu_mut()`) is enough on
199    /// its own to make `open_label` report `None`, with no separate bookkeeping to keep in sync.
200    #[test]
201    fn open_label_reads_none_once_the_inner_menu_state_closes_directly() {
202        let mut state = MenuBarState::new();
203        state.open(2, 1, selectable_at(&[0]));
204        state.menu_mut().close();
205        assert_eq!(state.open_label(), None);
206    }
207}