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}