retroglyph_ui/state/menu.rs
1use retroglyph_core::event::{Event, KeyCode};
2
3/// Selection index and scroll offset for an anchored popup menu. The menu is open iff a
4/// selection exists: there is no independent open flag to fall out of sync with it.
5///
6/// [`ListState`](crate::state::ListState)'s sibling for content where some rows (separators,
7/// headings) can never be selected: a dropdown, a combobox's list, a command palette.
8///
9/// Holds no reference to the menu's actual rows, the same design [`ListState`](crate::state::ListState) uses: every
10/// method that needs to know which rows exist takes `len` (the row count) and a `selectable`
11/// predicate (`impl Fn(usize) -> bool`) rather than a concrete row type, so this stays reusable
12/// across whatever row enum a call site defines (see
13/// [`Menu`](crate::widget::Menu)/[`MenuRow`](crate::widget::MenuRow) for this crate's own).
14/// `selectable` must report the same answer every time it's called this frame: passing a
15/// predicate that disagrees with what was drawn (e.g. after an `enabled` flag changed
16/// mid-frame) can select or skip past a row inconsistently with the render that follows.
17///
18/// Selection movement ([`select_next`](Self::select_next)/[`select_previous`](Self::select_previous))
19/// clamps at the ends: stepping past the last selectable row stays on it rather than wrapping,
20/// matching [`ListState`](crate::state::ListState)'s default. [`open`](Self::open) selects the first selectable row,
21/// skipping any leading separators/labels; a menu with no selectable row at all never opens
22/// (`open` becomes a no-op), so a caller can never end up with an open menu and no selection to
23/// navigate.
24///
25/// # Examples
26///
27/// ```
28/// use retroglyph_ui::state::MenuState;
29///
30/// // rows: [Separator, "Cut" (disabled), "Copy", "Paste"]
31/// let selectable = |i: usize| matches!(i, 2 | 3);
32///
33/// let mut state = MenuState::new();
34/// state.open(4, selectable);
35/// assert_eq!(state.selected(), Some(2)); // skipped the separator and the disabled item
36///
37/// state.select_next(4, selectable);
38/// assert_eq!(state.selected(), Some(3));
39/// state.select_next(4, selectable); // clamps: nothing selectable past index 3
40/// assert_eq!(state.selected(), Some(3));
41/// ```
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub struct MenuState {
44 selected: Option<usize>,
45 offset: usize,
46}
47
48impl MenuState {
49 /// A closed menu: nothing selected, no scroll.
50 #[must_use]
51 pub const fn new() -> Self {
52 Self {
53 selected: None,
54 offset: 0,
55 }
56 }
57
58 /// Whether the menu is currently open.
59 #[must_use]
60 pub const fn is_open(&self) -> bool {
61 self.selected.is_some()
62 }
63
64 /// The currently selected row index, if any. Always `None` while [`is_open`](Self::is_open)
65 /// is `false`: [`close`](Self::close) clears it.
66 #[must_use]
67 pub const fn selected(&self) -> Option<usize> {
68 self.selected
69 }
70
71 /// The current scroll offset (index of the first row a [`Menu`](crate::widget::Menu) draws,
72 /// not counting its own overflow indicator row).
73 #[must_use]
74 pub const fn offset(&self) -> usize {
75 self.offset
76 }
77
78 /// Set the scroll offset directly.
79 pub const fn set_offset(&mut self, offset: usize) {
80 self.offset = offset;
81 }
82
83 /// Select an explicit row index directly, e.g. after resolving a click to a row, or close
84 /// the menu by passing `None` (equivalent to [`close`](Self::close)). Stored verbatim, with
85 /// no `selectable` check of its own: callers that need "never select a disabled row" should
86 /// check that themselves first, the same responsibility
87 /// [`ListState::select`](crate::state::ListState::select) leaves to its own callers.
88 pub const fn select(&mut self, index: Option<usize>) {
89 match index {
90 Some(_) => self.selected = index,
91 None => self.close(),
92 }
93 }
94
95 /// Close the menu, clearing the selection and scroll offset.
96 pub const fn close(&mut self) {
97 self.selected = None;
98 self.offset = 0;
99 }
100
101 /// Open the menu and select the first selectable row among `0..len`, skipping any leading
102 /// separators/labels. A no-op (stays closed) if no row in `0..len` is selectable: an empty
103 /// menu never opens.
104 pub fn open(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
105 if let Some(first) = first_selectable(len, &selectable) {
106 self.selected = Some(first);
107 self.offset = 0;
108 }
109 }
110
111 /// [`close`](Self::close) if open, otherwise [`open`](Self::open).
112 pub fn toggle(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
113 if self.is_open() {
114 self.close();
115 } else {
116 self.open(len, selectable);
117 }
118 }
119
120 /// Move the selection to the next selectable row among `0..len`, skipping separators/labels.
121 /// Clamps at the last selectable row rather than wrapping. A no-op while the menu is closed:
122 /// navigation never opens it.
123 pub fn select_next(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
124 if self.selected.is_none() {
125 return;
126 }
127 self.selected = step(self.selected, 1, len, &selectable);
128 }
129
130 /// Move the selection to the previous selectable row among `0..len`, skipping
131 /// separators/labels. Clamps at the first selectable row rather than wrapping. A no-op while
132 /// the menu is closed: navigation never opens it.
133 pub fn select_previous(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
134 if self.selected.is_none() {
135 return;
136 }
137 self.selected = step(self.selected, -1, len, &selectable);
138 }
139
140 /// Nudge the scroll offset by the minimum amount needed to bring `selected` into the
141 /// `visible_height`-row window starting at `offset`. Same math as
142 /// [`ListState::ensure_visible`](crate::state::ListState::ensure_visible); see its docs for why this
143 /// is a no-op unless a selection exists and is out of view, and why it's cheap to call every
144 /// frame before rendering.
145 pub const fn ensure_visible(&mut self, visible_height: usize) {
146 let Some(selected) = self.selected else {
147 return;
148 };
149 if visible_height == 0 {
150 return;
151 }
152 if selected < self.offset {
153 self.offset = selected;
154 } else if selected >= self.offset.saturating_add(visible_height) {
155 self.offset = selected.saturating_add(1).saturating_sub(visible_height);
156 }
157 }
158
159 /// Move the scroll offset by `delta`, clamped at zero: the wheel-scroll half of "independent
160 /// wheel-scroll versus keyboard-nav scroll" -- this never touches `selected`, the same split
161 /// [`List`](crate::widget::List)/[`ListState::scroll_by`](crate::state::ListState::scroll_by) already
162 /// have between a click (which selects) and the wheel (which only scrolls).
163 pub fn scroll_by(&mut self, delta: i32) {
164 let next = i64::from(delta).saturating_add(i64::try_from(self.offset).unwrap_or(i64::MAX));
165 self.offset = next.max(0).try_into().unwrap_or(usize::MAX);
166 }
167
168 /// Apply one keyboard event while the menu is open: Up/Down move the selection (via
169 /// [`select_previous`](Self::select_previous)/[`select_next`](Self::select_next)), Enter
170 /// activates the current selection and [`close`](Self::close)s the menu, and Escape
171 /// [`close`](Self::close)s it without activating anything. Every other event, and every
172 /// event while [`is_open`](Self::is_open) is `false`, is ignored.
173 ///
174 /// Returns `Some(index)` for the one frame Enter activates a selectable row, `None`
175 /// otherwise -- including on Escape or an outside close, which intentionally report no
176 /// activation ("closes without firing").
177 ///
178 /// Only [`KeyEventKind::Press`](retroglyph_core::event::KeyEventKind::Press)/
179 /// [`Repeat`](retroglyph_core::event::KeyEventKind::Repeat) drive this (via
180 /// [`KeyEvent::is_down`](retroglyph_core::event::KeyEvent::is_down)); a release is ignored, the
181 /// same convention [`TextInputState::handle_event`](crate::state::TextInputState::handle_event) uses.
182 pub fn handle_event(
183 &mut self,
184 event: &Event,
185 len: usize,
186 selectable: impl Fn(usize) -> bool,
187 ) -> Option<usize> {
188 if !self.is_open() {
189 return None;
190 }
191 let Event::Key(key) = event else {
192 return None;
193 };
194 if !key.is_down() {
195 return None;
196 }
197 match key.code {
198 KeyCode::Up => {
199 self.select_previous(len, selectable);
200 None
201 }
202 KeyCode::Down => {
203 self.select_next(len, selectable);
204 None
205 }
206 KeyCode::Enter => {
207 let index = self.selected.filter(|&i| selectable(i))?;
208 self.close();
209 Some(index)
210 }
211 KeyCode::Escape => {
212 self.close();
213 None
214 }
215 _ => None,
216 }
217 }
218}
219
220/// The first index in `0..len` for which `selectable` reports `true`, if any.
221fn first_selectable(len: usize, selectable: &impl Fn(usize) -> bool) -> Option<usize> {
222 (0..len).find(|&i| selectable(i))
223}
224
225/// The last index in `0..len` for which `selectable` reports `true`, if any.
226fn last_selectable(len: usize, selectable: &impl Fn(usize) -> bool) -> Option<usize> {
227 (0..len).rev().find(|&i| selectable(i))
228}
229
230/// Shared step math for `select_next`/`select_previous`. `delta` is `1` or `-1`. A missing
231/// selection picks [`first_selectable`]/[`last_selectable`] depending on direction, the same
232/// "land somewhere sensible" rule [`ListState`](crate::state::ListState)'s own `stepped` uses. Clamps
233/// (returns `current` unchanged) once stepping any further would run off the end without
234/// finding another selectable row.
235fn step(
236 current: Option<usize>,
237 delta: i32,
238 len: usize,
239 selectable: &impl Fn(usize) -> bool,
240) -> Option<usize> {
241 if len == 0 {
242 return None;
243 }
244 let Some(start) = current else {
245 return if delta > 0 {
246 first_selectable(len, selectable)
247 } else {
248 last_selectable(len, selectable)
249 };
250 };
251 let Ok(len) = i32::try_from(len) else {
252 return current; // absurdly large len; leave selection alone
253 };
254 let mut i = i32::try_from(start).unwrap_or(0);
255 loop {
256 i += delta;
257 if i < 0 || i >= len {
258 return current; // clamp: no further selectable row in this direction
259 }
260 // `0 <= i < len`, and `len` came from a `usize` above, so this is in range.
261 #[allow(clippy::cast_sign_loss)]
262 let idx = i as usize;
263 if selectable(idx) {
264 return Some(idx);
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn selectable_at(indices: &'static [usize]) -> impl Fn(usize) -> bool {
274 move |i| indices.contains(&i)
275 }
276
277 #[test]
278 fn starts_closed_with_nothing_selected() {
279 let s = MenuState::new();
280 assert!(!s.is_open());
281 assert_eq!(s.selected(), None);
282 assert_eq!(s.offset(), 0);
283 }
284
285 #[test]
286 fn open_skips_a_leading_separator() {
287 let mut s = MenuState::new();
288 // row 0 is a separator (not selectable); row 1 is the first real item.
289 s.open(3, selectable_at(&[1, 2]));
290 assert!(s.is_open());
291 assert_eq!(s.selected(), Some(1));
292 }
293
294 #[test]
295 fn an_empty_menu_never_opens() {
296 let mut s = MenuState::new();
297 s.open(3, selectable_at(&[])); // every row is a separator/label
298 assert!(!s.is_open());
299 assert_eq!(s.selected(), None);
300 }
301
302 #[test]
303 fn toggle_opens_then_closes() {
304 let mut s = MenuState::new();
305 s.toggle(2, selectable_at(&[0, 1]));
306 assert!(s.is_open());
307 s.toggle(2, selectable_at(&[0, 1]));
308 assert!(!s.is_open());
309 assert_eq!(s.selected(), None);
310 }
311
312 #[test]
313 fn select_next_and_previous_skip_separators() {
314 let mut s = MenuState::new();
315 // rows: [Item(0), Separator(1), Item(2)]
316 let selectable = selectable_at(&[0, 2]);
317 s.open(3, &selectable);
318 assert_eq!(s.selected(), Some(0));
319 s.select_next(3, &selectable);
320 assert_eq!(s.selected(), Some(2)); // skipped the separator
321 s.select_next(3, &selectable);
322 assert_eq!(s.selected(), Some(2)); // clamps at the last selectable row
323 s.select_previous(3, &selectable);
324 assert_eq!(s.selected(), Some(0));
325 s.select_previous(3, &selectable);
326 assert_eq!(s.selected(), Some(0)); // clamps at the first selectable row
327 }
328
329 #[test]
330 fn label_rows_are_never_selected_by_navigation() {
331 let mut s = MenuState::new();
332 // rows: [Label(0), Item(1), Label(2), Item(3)]
333 let selectable = selectable_at(&[1, 3]);
334 s.open(4, &selectable);
335 assert_eq!(s.selected(), Some(1));
336 s.select_next(4, &selectable);
337 assert_eq!(s.selected(), Some(3));
338 }
339
340 #[test]
341 fn close_clears_selection_and_offset() {
342 let mut s = MenuState::new();
343 s.open(2, selectable_at(&[0, 1]));
344 s.set_offset(5);
345 s.close();
346 assert!(!s.is_open());
347 assert_eq!(s.selected(), None);
348 assert_eq!(s.offset(), 0);
349 }
350
351 #[test]
352 fn ensure_visible_scrolls_down_to_reveal_a_later_selection() {
353 let mut s = MenuState::new();
354 s.open(20, selectable_at(&[10]));
355 s.ensure_visible(4);
356 assert_eq!(s.offset(), 7); // [7, 11) puts 10 as the last visible row
357 }
358
359 #[test]
360 fn scroll_by_is_independent_of_the_selection() {
361 let mut s = MenuState::new();
362 s.open(5, selectable_at(&[0]));
363 s.scroll_by(2);
364 assert_eq!(s.offset(), 2);
365 assert_eq!(s.selected(), Some(0)); // wheel scroll never touches the selection
366 s.scroll_by(-5);
367 assert_eq!(s.offset(), 0); // clamped at zero
368 }
369
370 #[test]
371 fn escape_closes_without_activating() {
372 use retroglyph_core::event::{KeyEvent, KeyModifiers};
373
374 let mut s = MenuState::new();
375 s.open(2, selectable_at(&[0, 1]));
376 let event = Event::Key(KeyEvent::new(KeyCode::Escape, KeyModifiers::NONE));
377 let activated = s.handle_event(&event, 2, selectable_at(&[0, 1]));
378 assert_eq!(activated, None);
379 assert!(!s.is_open());
380 }
381
382 #[test]
383 fn enter_activates_the_selection_and_closes() {
384 use retroglyph_core::event::{KeyEvent, KeyModifiers};
385
386 let mut s = MenuState::new();
387 s.open(2, selectable_at(&[0, 1]));
388 s.select_next(2, selectable_at(&[0, 1]));
389 let event = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
390 let activated = s.handle_event(&event, 2, selectable_at(&[0, 1]));
391 assert_eq!(activated, Some(1));
392 assert!(!s.is_open()); // checkbox/plain items alike fire and close
393 }
394
395 #[test]
396 fn arrow_keys_navigate_while_open() {
397 use retroglyph_core::event::{KeyEvent, KeyModifiers};
398
399 let mut s = MenuState::new();
400 s.open(3, selectable_at(&[0, 1, 2]));
401 let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
402 s.handle_event(&down, 3, selectable_at(&[0, 1, 2]));
403 assert_eq!(s.selected(), Some(1));
404 }
405
406 #[test]
407 fn events_are_ignored_while_closed() {
408 use retroglyph_core::event::{KeyEvent, KeyModifiers};
409
410 let mut s = MenuState::new();
411 let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
412 assert_eq!(s.handle_event(&down, 3, selectable_at(&[0, 1, 2])), None);
413 assert_eq!(s.selected(), None);
414 }
415
416 #[test]
417 fn select_next_on_a_closed_menu_is_a_no_op() {
418 let mut s = MenuState::new();
419 s.select_next(3, selectable_at(&[0, 1, 2]));
420 assert!(!s.is_open());
421 assert_eq!(s.selected(), None);
422 }
423
424 #[test]
425 fn select_previous_on_a_closed_menu_is_a_no_op() {
426 let mut s = MenuState::new();
427 s.select_previous(3, selectable_at(&[0, 1, 2]));
428 assert!(!s.is_open());
429 assert_eq!(s.selected(), None);
430 }
431
432 #[test]
433 fn select_while_closed_does_not_report_a_selection() {
434 // A selection can no longer exist independently of openness: `select(Some(_))` on a
435 // closed menu opens it, rather than leaving behind the old invalid "selected but
436 // closed" state.
437 let mut s = MenuState::new();
438 assert!(!s.is_open());
439 s.select(Some(2));
440 assert!(s.is_open());
441 assert_eq!(s.selected(), Some(2));
442 }
443
444 #[test]
445 fn select_none_closes() {
446 let mut s = MenuState::new();
447 s.open(2, selectable_at(&[0, 1]));
448 s.set_offset(5);
449 s.select(None);
450 assert!(!s.is_open());
451 assert_eq!(s.selected(), None);
452 assert_eq!(s.offset(), 0);
453 }
454}