Skip to main content

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}