Skip to main content

retroglyph_ui/widget/
menu_bar.rs

1//! [`MenuBar`]: a horizontal row of labels, each anchoring a [`Menu`] popup.
2use retroglyph_core::color::Style;
3use retroglyph_core::event::{Event, KeyCode};
4use retroglyph_core::grid::{Pos, Rect, Size};
5
6use super::strip::Strip;
7use super::{Menu, MenuRow};
8use crate::Surface;
9use crate::align::Align;
10use crate::draw::fill_rect;
11use crate::interact::Sense;
12use crate::layout::{Side, anchored_rect};
13use crate::state::MenuBarState;
14use crate::text::draw_clipped;
15use crate::theme::Theme;
16use crate::ui::Ui;
17
18/// The three [`Id`](crate::interact::Interaction)s [`MenuBar::show`] needs.
19///
20/// One for the label row itself, and the two [`Menu::show`] needs internally for the popup it
21/// opens (its own content, and the full-`screen` backdrop that closes it on an outside click).
22/// Bundled into one argument rather than three separate ones so [`MenuBar::show`] itself stays
23/// under the `clippy` argument-count lint its unbundled predecessor tripped.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct MenuBarIds<Id> {
26    /// Identifies the label row for hit-testing.
27    pub label: Id,
28    /// Identifies the open popup's own content region; see [`Menu::show`].
29    pub popup_content: Id,
30    /// Identifies the open popup's full-`screen` backdrop; see [`Menu::show`].
31    pub popup_backdrop: Id,
32}
33
34/// A horizontal row of `titles`, each anchoring the [`Menu`] built from the matching slice of
35/// `rows`: a "File Edit View" menu bar.
36///
37/// The app-specific layer a dropdown's own anchored-popup core ([`Menu`]) doesn't have an
38/// opinion on. `titles` and `rows` are parallel slices (`rows[i]` is the popup shown for `titles[i]`); a
39/// mismatched length is a caller bug the same way it would be for two `Vec`s meant to stay in
40/// lockstep, not something this widget can check for you.
41///
42/// Interaction lives entirely in [`MenuBar::show`]/[`MenuBar::handle_event`], mirroring the
43/// click-and-keyboard half of [`Menu::show`]/[`MenuState::handle_event`](crate::state::MenuState::handle_event):
44///
45/// - A click on a label [toggles](crate::state::MenuBarState::toggle) that label's popup open or
46///   closed.
47/// - Hovering a *different* label while one is already open switches to it. A cold hover (no
48///   popup open yet) does nothing: this is deliberately not a `Button`-style hover highlight
49///   that opens on its own, matching how a desktop menu bar only starts following the pointer
50///   once you've clicked into it once.
51/// - [`MenuBar::handle_event`]'s Left/Right step which label is open (clamped at the ends, not
52///   wrapping, matching [`MenuState::select_next`](crate::state::MenuState::select_next)'s own
53///   default); Up/Down/Enter/Escape forward to the open label's own
54///   [`MenuState::handle_event`](crate::state::MenuState::handle_event).
55///
56/// `style`/`open_style` name the unopened/opened label styles (not `selected_style`: nothing
57/// here is a persistent selection the way [`Tabs`](super::Tabs)'s is, it's literally "this
58/// label's popup is open right now" and reverts the instant it closes). Both default to
59/// [`Theme::DARK`] (as if [`MenuBar::theme`] had been called), and that same theme is forwarded
60/// to the [`Menu`] popup [`MenuBar::show`] builds internally, so the bar and its popups always
61/// read as one themed unit.
62///
63/// # Examples
64///
65/// ```
66/// use retroglyph_core::grid::{Grid, Rect};
67/// use retroglyph_ui::state::MenuBarState;
68/// use retroglyph_ui::widget::{Marker, MenuBar, MenuRow};
69/// use retroglyph_ui::Surface;
70///
71/// let file_rows = [
72///     MenuRow::Item { label: "New", marker: Marker::None, enabled: true },
73///     MenuRow::Item { label: "Open", marker: Marker::None, enabled: true },
74/// ];
75/// let edit_rows = [
76///     MenuRow::Item { label: "Cut", marker: Marker::None, enabled: true },
77/// ];
78/// let titles = ["File", "Edit"];
79/// let rows: [&[MenuRow]; 2] = [&file_rows, &edit_rows];
80///
81/// let bar = MenuBar::new(&titles, &rows);
82/// let mut state = MenuBarState::new();
83///
84/// let area = Rect::new(0, 0, 20, 1);
85/// let mut grid = Grid::new(20, 1);
86/// bar.draw(&mut Surface::new(&mut grid, area, 0), state.open_label());
87/// ```
88#[derive(Clone, Copy, Debug)]
89pub struct MenuBar<'a> {
90    titles: &'a [&'a str],
91    rows: &'a [&'a [MenuRow<'a>]],
92    style: Style,
93    open_style: Style,
94    column_spacing: u16,
95    theme: Theme,
96}
97
98impl<'a> MenuBar<'a> {
99    /// A menu bar over `titles`, each anchoring the popup built from the matching entry of
100    /// `rows`, styled from [`Theme::DARK`] (as if [`MenuBar::theme`] had been called).
101    #[must_use]
102    pub fn new(titles: &'a [&'a str], rows: &'a [&'a [MenuRow<'a>]]) -> Self {
103        Self {
104            titles,
105            rows,
106            style: Style::new(),
107            open_style: Style::new(),
108            column_spacing: 1,
109            theme: Theme::DARK,
110        }
111        .theme(Theme::DARK)
112    }
113
114    /// Set the style of an unopened label.
115    #[must_use]
116    pub const fn style(mut self, style: Style) -> Self {
117        self.style = style;
118        self
119    }
120
121    /// Set the style of the label whose popup is currently open.
122    #[must_use]
123    pub const fn open_style(mut self, style: Style) -> Self {
124        self.open_style = style;
125        self
126    }
127
128    /// Set the number of blank columns between labels.
129    #[must_use]
130    pub const fn column_spacing(mut self, spacing: u16) -> Self {
131        self.column_spacing = spacing;
132        self
133    }
134
135    /// Applies `theme`'s named roles to this bar and the [`Menu`] popup [`MenuBar::show`] builds
136    /// from it: `style` becomes `theme.dim` on `theme.panel_bg`, `open_style` becomes `theme.bg`
137    /// on `theme.accent` (the same unselected/selected mapping [`Tabs::theme`](super::Tabs::theme)
138    /// uses), and the popup itself is themed via [`Menu::theme`].
139    ///
140    /// Call before any manual [`MenuBar::style`]/[`MenuBar::open_style`] override you want to
141    /// keep.
142    #[must_use]
143    pub fn theme(mut self, theme: Theme) -> Self {
144        self.style = Style::new().fg(theme.dim).bg(theme.panel_bg);
145        self.open_style = Style::new().fg(theme.bg).bg(theme.accent);
146        self.theme = theme;
147        self
148    }
149}
150
151impl MenuBar<'_> {
152    /// Draw this bar into `surface.area()`, highlighting `open` (if any) with
153    /// [`MenuBar::open_style`].
154    pub fn draw(&self, surface: &mut Surface<'_>, open: Option<usize>) {
155        let area = surface.area();
156        if area.width() == 0 || area.height() == 0 {
157            return;
158        }
159
160        // `Strip::columns` reports absolute `x`, matching `area`'s own space (needed so
161        // `Strip::index_at` can compare it directly against an absolute pointer position); `put`/
162        // `print`/`fill_rect` below address this surface's own local coordinates instead, so
163        // `area.left()` is subtracted back out at the point of drawing.
164        let strip = Strip::new(self.titles, self.column_spacing);
165        for (index, abs_x, text_width) in strip.columns(area) {
166            let x = abs_x - area.left();
167            let title = self.titles[index];
168            let style = if Some(index) == open {
169                self.open_style
170            } else {
171                self.style
172            };
173            if Some(index) == open && text_width > 0 {
174                fill_rect(
175                    surface,
176                    Rect::new(x, 0, text_width, 1),
177                    ' ',
178                    Style::new().bg(style.background()),
179                );
180            }
181            let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
182        }
183    }
184
185    /// The index of the label whose column range contains `pos`, or `None` if `pos` falls in the
186    /// spacing between labels, past the last drawn label, or outside `area` entirely.
187    fn label_at(&self, area: Rect, pos: Pos) -> Option<usize> {
188        Strip::new(self.titles, self.column_spacing).index_at(area, pos)
189    }
190
191    /// `label`'s own rect within `area`: [`Strip::columns`]' entry for it, or `area` unchanged if
192    /// `label` is out of range (defensive only; every caller here gets `label` from
193    /// [`MenuBarState::open_label`](crate::state::MenuBarState::open_label), which never reports
194    /// an index [`MenuBar::show`] didn't just draw).
195    fn label_rect(&self, area: Rect, label: usize) -> Rect {
196        Strip::new(self.titles, self.column_spacing)
197            .columns(area)
198            .find(|&(index, ..)| index == label)
199            .map_or(area, |(_, x, text_width)| {
200                Rect::new(x, area.top(), text_width.max(1), area.height())
201            })
202    }
203
204    /// `label`'s row count and selectable predicate, as [`MenuState`](crate::state::MenuState)'s
205    /// own methods want them.
206    fn rows_of(&self, label: usize) -> (usize, impl Fn(usize) -> bool + '_) {
207        let rows = self.rows[label];
208        (rows.len(), move |i: usize| rows[i].is_selectable())
209    }
210
211    /// Draw and interact with this bar, and whichever label's popup [`MenuBarState::open_label`]
212    /// reports, for one frame.
213    ///
214    /// The popup (if any) is shown *before* the label row itself is registered/drawn, the
215    /// reverse of [`Menu::show`]'s own internal ordering of its backdrop-then-content: `show`
216    /// there registers a full-`screen` backdrop so a click anywhere outside the popup closes it,
217    /// but that backdrop would otherwise sit on top of (and shadow hit-testing for) every label
218    /// in this bar, including the very one whose click should switch or close it. Drawing/
219    /// registering the label row after the popup puts the bar back on top for hit-testing (see
220    /// [`Ui::modal`]'s "later registered wins" rule), so a click or hover on any label -- open
221    /// one included -- always reaches the bar, and only a click truly outside both the bar and
222    /// the popup reaches the backdrop.
223    ///
224    /// A consequence of that ordering: the popup for `state`'s own change (open, close, or
225    /// switch) applied in this same call -- from a click resolved below, or from
226    /// [`MenuBar::handle_event`] called earlier this frame -- isn't drawn until the *next*
227    /// frame's `show`, since this call already decided what to draw from `state.open_label()`
228    /// before that change happens. One extra frame on top of the usual one-frame click-latency
229    /// this crate already has everywhere (see [`Interaction`](crate::interact::Interaction)),
230    /// not a bug: correctly prioritizing the bar over the popup's own backdrop for every
231    /// *subsequent* frame (so a click or hover can still reach and switch/close it) requires
232    /// registering the bar after the popup, which is exactly what makes the fresh transition
233    /// itself one frame slower to render.
234    ///
235    /// `popup_size` is used for every label's popup; `bar_area` anchors each label's own popup
236    /// [`Side::Below`] itself via [`anchored_rect`], clamped to `screen`.
237    ///
238    /// Returns `Some((label, row))` for the one frame a selectable row in `label`'s popup is
239    /// clicked, the same "checkbox items fire and close" contract [`Menu::show`] has.
240    #[must_use]
241    pub fn show<Id: Copy + PartialEq>(
242        &self,
243        ui: &mut Ui<'_, '_, Id>,
244        screen: Rect,
245        bar_area: Rect,
246        popup_size: Size,
247        ids: MenuBarIds<Id>,
248        state: &mut MenuBarState,
249    ) -> Option<(usize, usize)> {
250        let mut activated = None;
251
252        if let Some(open) = state.open_label() {
253            let anchor = self.label_rect(bar_area, open);
254            let popup_area = anchored_rect(anchor, popup_size, Side::Below, screen);
255            if let Some(row) = Menu::new(self.rows[open]).theme(self.theme).show(
256                ui,
257                screen,
258                popup_area,
259                ids.popup_content,
260                ids.popup_backdrop,
261                state.menu_mut(),
262            ) {
263                activated = Some((open, row));
264            }
265        }
266
267        let (response, mut surface) = ui.region(bar_area, ids.label, Sense::click() | Sense::HOVER);
268        self.draw(&mut surface, state.open_label());
269
270        let hovered_label = response
271            .pointer_pos()
272            .and_then(|pos| self.label_at(bar_area, pos));
273
274        if response.clicked()
275            && let Some(label) = hovered_label
276        {
277            let (len, selectable) = self.rows_of(label);
278            state.toggle(label, len, selectable);
279        }
280
281        match hovered_label {
282            Some(label) => {
283                let (len, selectable) = self.rows_of(label);
284                state.hover(Some(label), len, selectable);
285            }
286            None => state.hover(None, 0, |_| false),
287        }
288
289        activated
290    }
291
292    /// Apply one keyboard event to `state`: Left/Right move which label is open, clamped at the
293    /// ends (see the type docs); every other event forwards to the open label's own
294    /// [`MenuState::handle_event`](crate::state::MenuState::handle_event), returning
295    /// `Some((label, row))` for the one frame Enter activates a row.
296    ///
297    /// A no-op, on every key, while nothing is open: Left/Right don't open a label from cold any
298    /// more than a hover does (see the type docs), and there is no open label's `MenuState` to
299    /// forward anything else to.
300    pub fn handle_event(&self, event: &Event, state: &mut MenuBarState) -> Option<(usize, usize)> {
301        let open = state.open_label()?;
302
303        if let Event::Key(key) = event
304            && key.is_down()
305        {
306            let delta = match key.code {
307                KeyCode::Left => -1,
308                KeyCode::Right => 1,
309                _ => 0,
310            };
311            if delta != 0 {
312                self.step_open_label(state, open, delta);
313                return None;
314            }
315        }
316
317        let (len, selectable) = self.rows_of(open);
318        let row = state.menu_mut().handle_event(event, len, selectable)?;
319        Some((open, row))
320    }
321
322    /// Move the open label by `delta` (`1` or `-1`), clamped to `0..titles.len()`, reopening the
323    /// new label's popup. A no-op if that clamps back to `current` (already at an end) or there
324    /// are no labels at all.
325    fn step_open_label(&self, state: &mut MenuBarState, current: usize, delta: i32) {
326        let Ok(len) = i32::try_from(self.titles.len()) else {
327            return; // absurdly large `titles`; leave the open label alone
328        };
329        if len == 0 {
330            return;
331        }
332        let Ok(current_i32) = i32::try_from(current) else {
333            return;
334        };
335        let next = (current_i32 + delta).clamp(0, len - 1);
336        // `0 <= next < len`, and `len` came from a `usize` above, so this fits a `usize`.
337        #[allow(clippy::cast_sign_loss)]
338        let next = next as usize;
339        if next != current {
340            let (len, selectable) = self.rows_of(next);
341            state.open(next, len, selectable);
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use retroglyph_core::event::{
349        Event, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
350    };
351    use retroglyph_core::grid::Grid;
352
353    use super::*;
354    use crate::interact::Interaction;
355    use crate::widget::Marker;
356
357    const FILE_ROWS: [MenuRow<'static>; 2] = [
358        MenuRow::Item {
359            label: "New",
360            marker: Marker::None,
361            enabled: true,
362        },
363        MenuRow::Item {
364            label: "Open",
365            marker: Marker::None,
366            enabled: true,
367        },
368    ];
369    const EDIT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
370        label: "Cut",
371        marker: Marker::None,
372        enabled: true,
373    }];
374    const VIEW_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
375        label: "Zoom",
376        marker: Marker::None,
377        enabled: true,
378    }];
379
380    const TITLES: [&str; 3] = ["File", "Edit", "View"];
381
382    fn rows() -> [&'static [MenuRow<'static>]; 3] {
383        [&FILE_ROWS, &EDIT_ROWS, &VIEW_ROWS]
384    }
385
386    #[derive(Clone, Copy, PartialEq, Eq)]
387    enum Id {
388        Bar,
389        PopupContent,
390        PopupBackdrop,
391    }
392
393    fn show(
394        bar: &MenuBar<'_>,
395        interaction: &mut Interaction<Id>,
396        grid: &mut Grid,
397        screen: Rect,
398        state: &mut MenuBarState,
399    ) -> Option<(usize, usize)> {
400        interaction.frame(&mut Surface::new(grid, screen, 0), |ui| {
401            bar.show(
402                ui,
403                screen,
404                Rect::new(0, 0, 20, 1),
405                Size::new(10, 4),
406                MenuBarIds {
407                    label: Id::Bar,
408                    popup_content: Id::PopupContent,
409                    popup_backdrop: Id::PopupBackdrop,
410                },
411                state,
412            )
413        })
414    }
415
416    fn click(interaction: &mut Interaction<Id>, pos: Pos) {
417        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
418            MouseEventKind::Down(MouseButton::Left),
419            pos,
420            KeyModifiers::NONE,
421        )));
422        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
423            MouseEventKind::Up(MouseButton::Left),
424            pos,
425            KeyModifiers::NONE,
426        )));
427    }
428
429    fn hover(interaction: &mut Interaction<Id>, pos: Pos) {
430        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
431            MouseEventKind::Moved,
432            pos,
433            KeyModifiers::NONE,
434        )));
435    }
436
437    #[test]
438    fn draws_every_title_left_to_right() {
439        let rows = rows();
440        let bar = MenuBar::new(&TITLES, &rows);
441        let area = Rect::new(0, 0, 20, 1);
442        let mut grid = Grid::new(20, 1);
443        bar.draw(&mut Surface::new(&mut grid, area, 0), None);
444
445        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'F'); // "File"
446        // "File" (4) + column_spacing (1) = "Edit" starts at column 5.
447        assert_eq!(grid[Pos::new(5, 0)].glyph(), 'E');
448    }
449
450    #[test]
451    fn cold_hover_does_not_open_anything() {
452        let rows = rows();
453        let bar = MenuBar::new(&TITLES, &rows);
454        let mut interaction = Interaction::<Id>::new();
455        let screen = Rect::new(0, 0, 20, 10);
456        let mut state = MenuBarState::new();
457
458        let mut grid = Grid::new(20, 10);
459        show(&bar, &mut interaction, &mut grid, screen, &mut state);
460
461        hover(&mut interaction, Pos::new(0, 0)); // over "File", but never clicked
462
463        let mut grid = Grid::new(20, 10);
464        show(&bar, &mut interaction, &mut grid, screen, &mut state);
465
466        assert_eq!(state.open_label(), None);
467    }
468
469    #[test]
470    fn click_opens_the_clicked_label() {
471        let rows = rows();
472        let bar = MenuBar::new(&TITLES, &rows);
473        let mut interaction = Interaction::<Id>::new();
474        let screen = Rect::new(0, 0, 20, 10);
475        let mut state = MenuBarState::new();
476
477        let mut grid = Grid::new(20, 10);
478        show(&bar, &mut interaction, &mut grid, screen, &mut state);
479
480        click(&mut interaction, Pos::new(0, 0)); // over "File"
481
482        let mut grid = Grid::new(20, 10);
483        show(&bar, &mut interaction, &mut grid, screen, &mut state);
484
485        assert_eq!(state.open_label(), Some(0));
486    }
487
488    /// Mirrors `Tabs`' `tabs_wide_title_does_not_overlap_the_next_tab`: a wide-glyph label must
489    /// not shadow the next label's own hit-test range.
490    #[test]
491    fn click_past_a_wide_glyph_label_opens_the_next_label() {
492        const WIDE_TITLES: [&str; 2] = ["設定", "次"];
493        const NEXT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
494            label: "Item",
495            marker: Marker::None,
496            enabled: true,
497        }];
498        let wide_rows: [&[MenuRow<'static>]; 2] = [&NEXT_ROWS, &NEXT_ROWS];
499        let bar = MenuBar::new(&WIDE_TITLES, &wide_rows);
500        let mut interaction = Interaction::<Id>::new();
501        let screen = Rect::new(0, 0, 20, 10);
502        let mut state = MenuBarState::new();
503
504        let mut grid = Grid::new(20, 10);
505        show(&bar, &mut interaction, &mut grid, screen, &mut state);
506
507        // "設定" occupies columns [0, 4); "次" starts at column 5 (4 + column_spacing 1).
508        click(&mut interaction, Pos::new(5, 0));
509
510        let mut grid = Grid::new(20, 10);
511        show(&bar, &mut interaction, &mut grid, screen, &mut state);
512
513        assert_eq!(state.open_label(), Some(1));
514    }
515
516    #[test]
517    fn click_again_closes_the_same_label() {
518        let rows = rows();
519        let bar = MenuBar::new(&TITLES, &rows);
520        let mut interaction = Interaction::<Id>::new();
521        let screen = Rect::new(0, 0, 20, 10);
522        let mut state = MenuBarState::new();
523        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
524
525        let mut grid = Grid::new(20, 10);
526        show(&bar, &mut interaction, &mut grid, screen, &mut state);
527
528        click(&mut interaction, Pos::new(0, 0)); // over "File" again
529
530        let mut grid = Grid::new(20, 10);
531        show(&bar, &mut interaction, &mut grid, screen, &mut state);
532
533        assert_eq!(state.open_label(), None);
534    }
535
536    #[test]
537    fn hovering_another_label_while_one_is_open_switches_to_it() {
538        let rows = rows();
539        let bar = MenuBar::new(&TITLES, &rows);
540        let mut interaction = Interaction::<Id>::new();
541        let screen = Rect::new(0, 0, 20, 10);
542        let mut state = MenuBarState::new();
543        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
544
545        let mut grid = Grid::new(20, 10);
546        show(&bar, &mut interaction, &mut grid, screen, &mut state);
547
548        hover(&mut interaction, Pos::new(5, 0)); // over "Edit", no click
549
550        let mut grid = Grid::new(20, 10);
551        show(&bar, &mut interaction, &mut grid, screen, &mut state);
552
553        assert_eq!(state.open_label(), Some(1));
554    }
555
556    #[test]
557    fn clicking_a_row_activates_it_and_closes_the_bar() {
558        let rows = rows();
559        let bar = MenuBar::new(&TITLES, &rows);
560        let mut interaction = Interaction::<Id>::new();
561        let screen = Rect::new(0, 0, 20, 10);
562        let mut state = MenuBarState::new();
563        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
564
565        // Frame 1 registers the open popup's rows.
566        let mut grid = Grid::new(20, 10);
567        show(&bar, &mut interaction, &mut grid, screen, &mut state);
568
569        // "File"'s popup is anchored below `bar_area` (y=1), "Open" is its second row (y=2).
570        click(&mut interaction, Pos::new(1, 2));
571
572        let mut grid = Grid::new(20, 10);
573        let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);
574
575        assert_eq!(activated, Some((0, 1)));
576        assert_eq!(state.open_label(), None);
577    }
578
579    #[test]
580    fn clicking_outside_the_bar_and_popup_closes_without_activating() {
581        let rows = rows();
582        let bar = MenuBar::new(&TITLES, &rows);
583        let mut interaction = Interaction::<Id>::new();
584        let screen = Rect::new(0, 0, 20, 10);
585        let mut state = MenuBarState::new();
586        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
587
588        let mut grid = Grid::new(20, 10);
589        show(&bar, &mut interaction, &mut grid, screen, &mut state);
590
591        click(&mut interaction, Pos::new(19, 9)); // far outside both the bar and the popup
592
593        let mut grid = Grid::new(20, 10);
594        let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);
595
596        assert_eq!(activated, None);
597        assert_eq!(state.open_label(), None);
598    }
599
600    #[test]
601    fn right_arrow_moves_to_the_next_label() {
602        let rows = rows();
603        let bar = MenuBar::new(&TITLES, &rows);
604        let mut state = MenuBarState::new();
605        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
606
607        let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
608        let activated = bar.handle_event(&right, &mut state);
609
610        assert_eq!(activated, None);
611        assert_eq!(state.open_label(), Some(1));
612    }
613
614    #[test]
615    fn left_right_clamp_at_the_ends_without_wrapping() {
616        let rows = rows();
617        let bar = MenuBar::new(&TITLES, &rows);
618        let mut state = MenuBarState::new();
619        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
620
621        let left = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
622        bar.handle_event(&left, &mut state);
623        assert_eq!(state.open_label(), Some(0)); // clamped: already at the first label
624    }
625
626    #[test]
627    fn left_right_are_a_no_op_while_nothing_is_open() {
628        let rows = rows();
629        let bar = MenuBar::new(&TITLES, &rows);
630        let mut state = MenuBarState::new();
631
632        let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
633        bar.handle_event(&right, &mut state);
634        assert_eq!(state.open_label(), None); // no cold-open, matching a cold hover
635    }
636
637    #[test]
638    fn arrow_down_then_enter_navigates_and_activates_the_open_popup() {
639        let rows = rows();
640        let bar = MenuBar::new(&TITLES, &rows);
641        let mut state = MenuBarState::new();
642        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
643
644        let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
645        bar.handle_event(&down, &mut state);
646        assert_eq!(state.menu().selected(), Some(1)); // moved from "New" to "Open"
647
648        let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
649        let activated = bar.handle_event(&enter, &mut state);
650        assert_eq!(activated, Some((0, 1)));
651        assert_eq!(state.open_label(), None);
652    }
653
654    /// A pointer that never moves off the label it opened must not immediately undo a
655    /// keyboard-driven `handle_event` switch to a different label: re-resolving "is the pointer
656    /// over a different label than what's open" fresh every frame (rather than only on a
657    /// genuinely new hover) would otherwise snap `open_label` straight back the very next frame
658    /// `show` runs, since the stale, unmoved pointer position still resolves over the label that
659    /// was open *before* the keypress.
660    #[test]
661    fn a_stationary_pointer_does_not_fight_a_keyboard_driven_switch() {
662        let rows = rows();
663        let bar = MenuBar::new(&TITLES, &rows);
664        let mut interaction = Interaction::<Id>::new();
665        let screen = Rect::new(0, 0, 20, 10);
666        let mut state = MenuBarState::new();
667
668        // Frame 1: register the bar's hitbox, with the pointer already resolved over "File".
669        hover(&mut interaction, Pos::new(0, 0));
670        let mut grid = Grid::new(20, 10);
671        show(&bar, &mut interaction, &mut grid, screen, &mut state);
672
673        // Click "File" open; the pointer stays at the same position throughout (no further
674        // `hover` call).
675        click(&mut interaction, Pos::new(0, 0));
676        let mut grid = Grid::new(20, 10);
677        show(&bar, &mut interaction, &mut grid, screen, &mut state);
678        assert_eq!(state.open_label(), Some(0));
679
680        // Keyboard-driven switch to "Edit", with the pointer still resting over "File".
681        let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
682        bar.handle_event(&right, &mut state);
683        assert_eq!(state.open_label(), Some(1));
684
685        // The next `show` must not resolve the still-stationary pointer over "File" as a fresh
686        // hover and switch back to it.
687        let mut grid = Grid::new(20, 10);
688        show(&bar, &mut interaction, &mut grid, screen, &mut state);
689        assert_eq!(state.open_label(), Some(1));
690    }
691}