retroglyph-ui 0.5.1

Immediate-mode terminal UI toolkit for retroglyph: widgets, layout, input/focus, theming, and animation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
//! [`MenuBar`]: a horizontal row of labels, each anchoring a [`Menu`] popup.
use retroglyph_core::color::Style;
use retroglyph_core::event::{Event, KeyCode};
use retroglyph_core::grid::{Pos, Rect, Size};

use super::strip::Strip;
use super::{Menu, MenuRow};
use crate::Surface;
use crate::align::Align;
use crate::draw::fill_rect;
use crate::interact::Sense;
use crate::layout::{Side, anchored_rect};
use crate::state::MenuBarState;
use crate::text::draw_clipped;
use crate::theme::Theme;
use crate::ui::Ui;

/// The three [`Id`](crate::interact::Interaction)s [`MenuBar::show`] needs.
///
/// One for the label row itself, and the two [`Menu::show`] needs internally for the popup it
/// opens (its own content, and the full-`screen` backdrop that closes it on an outside click).
/// Bundled into one argument rather than three separate ones so [`MenuBar::show`] itself stays
/// under the `clippy` argument-count lint its unbundled predecessor tripped.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MenuBarIds<Id> {
    /// Identifies the label row for hit-testing.
    pub label: Id,
    /// Identifies the open popup's own content region; see [`Menu::show`].
    pub popup_content: Id,
    /// Identifies the open popup's full-`screen` backdrop; see [`Menu::show`].
    pub popup_backdrop: Id,
}

/// A horizontal row of `titles`, each anchoring the [`Menu`] built from the matching slice of
/// `rows`: a "File Edit View" menu bar.
///
/// The app-specific layer a dropdown's own anchored-popup core ([`Menu`]) doesn't have an
/// opinion on. `titles` and `rows` are parallel slices (`rows[i]` is the popup shown for `titles[i]`); a
/// mismatched length is a caller bug the same way it would be for two `Vec`s meant to stay in
/// lockstep, not something this widget can check for you.
///
/// Interaction lives entirely in [`MenuBar::show`]/[`MenuBar::handle_event`], mirroring the
/// click-and-keyboard half of [`Menu::show`]/[`MenuState::handle_event`](crate::state::MenuState::handle_event):
///
/// - A click on a label [toggles](crate::state::MenuBarState::toggle) that label's popup open or
///   closed.
/// - Hovering a *different* label while one is already open switches to it. A cold hover (no
///   popup open yet) does nothing: this is deliberately not a `Button`-style hover highlight
///   that opens on its own, matching how a desktop menu bar only starts following the pointer
///   once you've clicked into it once.
/// - [`MenuBar::handle_event`]'s Left/Right step which label is open (clamped at the ends, not
///   wrapping, matching [`MenuState::select_next`](crate::state::MenuState::select_next)'s own
///   default); Up/Down/Enter/Escape forward to the open label's own
///   [`MenuState::handle_event`](crate::state::MenuState::handle_event).
///
/// `style`/`open_style` name the unopened/opened label styles (not `selected_style`: nothing
/// here is a persistent selection the way [`Tabs`](super::Tabs)'s is, it's literally "this
/// label's popup is open right now" and reverts the instant it closes). Both default to
/// [`Theme::DARK`] (as if [`MenuBar::theme`] had been called), and that same theme is forwarded
/// to the [`Menu`] popup [`MenuBar::show`] builds internally, so the bar and its popups always
/// read as one themed unit.
///
/// # Examples
///
/// ```
/// use retroglyph_core::grid::{Grid, Rect};
/// use retroglyph_ui::state::MenuBarState;
/// use retroglyph_ui::widget::{Marker, MenuBar, MenuRow};
/// use retroglyph_ui::Surface;
///
/// let file_rows = [
///     MenuRow::Item { label: "New", marker: Marker::None, enabled: true },
///     MenuRow::Item { label: "Open", marker: Marker::None, enabled: true },
/// ];
/// let edit_rows = [
///     MenuRow::Item { label: "Cut", marker: Marker::None, enabled: true },
/// ];
/// let titles = ["File", "Edit"];
/// let rows: [&[MenuRow]; 2] = [&file_rows, &edit_rows];
///
/// let bar = MenuBar::new(&titles, &rows);
/// let mut state = MenuBarState::new();
///
/// let area = Rect::new(0, 0, 20, 1);
/// let mut grid = Grid::new(20, 1);
/// bar.draw(&mut Surface::new(&mut grid, area, 0), state.open_label());
/// ```
#[derive(Clone, Copy, Debug)]
pub struct MenuBar<'a> {
    titles: &'a [&'a str],
    rows: &'a [&'a [MenuRow<'a>]],
    style: Style,
    open_style: Style,
    column_spacing: u16,
    theme: Theme,
}

impl<'a> MenuBar<'a> {
    /// A menu bar over `titles`, each anchoring the popup built from the matching entry of
    /// `rows`, styled from [`Theme::DARK`] (as if [`MenuBar::theme`] had been called).
    #[must_use]
    pub fn new(titles: &'a [&'a str], rows: &'a [&'a [MenuRow<'a>]]) -> Self {
        Self {
            titles,
            rows,
            style: Style::new(),
            open_style: Style::new(),
            column_spacing: 1,
            theme: Theme::DARK,
        }
        .theme(Theme::DARK)
    }

    /// Set the style of an unopened label.
    #[must_use]
    pub const fn style(mut self, style: Style) -> Self {
        self.style = style;
        self
    }

    /// Set the style of the label whose popup is currently open.
    #[must_use]
    pub const fn open_style(mut self, style: Style) -> Self {
        self.open_style = style;
        self
    }

    /// Set the number of blank columns between labels.
    #[must_use]
    pub const fn column_spacing(mut self, spacing: u16) -> Self {
        self.column_spacing = spacing;
        self
    }

    /// Applies `theme`'s named roles to this bar and the [`Menu`] popup [`MenuBar::show`] builds
    /// from it: `style` becomes `theme.dim` on `theme.panel_bg`, `open_style` becomes `theme.bg`
    /// on `theme.accent` (the same unselected/selected mapping [`Tabs::theme`](super::Tabs::theme)
    /// uses), and the popup itself is themed via [`Menu::theme`].
    ///
    /// Call before any manual [`MenuBar::style`]/[`MenuBar::open_style`] override you want to
    /// keep.
    #[must_use]
    pub fn theme(mut self, theme: Theme) -> Self {
        self.style = Style::new().fg(theme.dim).bg(theme.panel_bg);
        self.open_style = Style::new().fg(theme.bg).bg(theme.accent);
        self.theme = theme;
        self
    }
}

impl MenuBar<'_> {
    /// Draw this bar into `surface.area()`, highlighting `open` (if any) with
    /// [`MenuBar::open_style`].
    pub fn draw(&self, surface: &mut Surface<'_>, open: Option<usize>) {
        let area = surface.area();
        if area.width() == 0 || area.height() == 0 {
            return;
        }

        // `Strip::columns` reports absolute `x`, matching `area`'s own space (needed so
        // `Strip::index_at` can compare it directly against an absolute pointer position); `put`/
        // `print`/`fill_rect` below address this surface's own local coordinates instead, so
        // `area.left()` is subtracted back out at the point of drawing.
        let strip = Strip::new(self.titles, self.column_spacing);
        for (index, abs_x, text_width) in strip.columns(area) {
            let x = abs_x - area.left();
            let title = self.titles[index];
            let style = if Some(index) == open {
                self.open_style
            } else {
                self.style
            };
            if Some(index) == open && text_width > 0 {
                fill_rect(
                    surface,
                    Rect::new(x, 0, text_width, 1),
                    ' ',
                    Style::new().bg(style.background()),
                );
            }
            let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
        }
    }

    /// The index of the label whose column range contains `pos`, or `None` if `pos` falls in the
    /// spacing between labels, past the last drawn label, or outside `area` entirely.
    fn label_at(&self, area: Rect, pos: Pos) -> Option<usize> {
        Strip::new(self.titles, self.column_spacing).index_at(area, pos)
    }

    /// `label`'s own rect within `area`: [`Strip::columns`]' entry for it, or `area` unchanged if
    /// `label` is out of range (defensive only; every caller here gets `label` from
    /// [`MenuBarState::open_label`](crate::state::MenuBarState::open_label), which never reports
    /// an index [`MenuBar::show`] didn't just draw).
    fn label_rect(&self, area: Rect, label: usize) -> Rect {
        Strip::new(self.titles, self.column_spacing)
            .columns(area)
            .find(|&(index, ..)| index == label)
            .map_or(area, |(_, x, text_width)| {
                Rect::new(x, area.top(), text_width.max(1), area.height())
            })
    }

    /// `label`'s row count and selectable predicate, as [`MenuState`](crate::state::MenuState)'s
    /// own methods want them.
    fn rows_of(&self, label: usize) -> (usize, impl Fn(usize) -> bool + '_) {
        let rows = self.rows[label];
        (rows.len(), move |i: usize| rows[i].is_selectable())
    }

    /// Draw and interact with this bar, and whichever label's popup [`MenuBarState::open_label`]
    /// reports, for one frame.
    ///
    /// The popup (if any) is shown *before* the label row itself is registered/drawn, the
    /// reverse of [`Menu::show`]'s own internal ordering of its backdrop-then-content: `show`
    /// there registers a full-`screen` backdrop so a click anywhere outside the popup closes it,
    /// but that backdrop would otherwise sit on top of (and shadow hit-testing for) every label
    /// in this bar, including the very one whose click should switch or close it. Drawing/
    /// registering the label row after the popup puts the bar back on top for hit-testing (see
    /// [`Ui::modal`]'s "later registered wins" rule), so a click or hover on any label -- open
    /// one included -- always reaches the bar, and only a click truly outside both the bar and
    /// the popup reaches the backdrop.
    ///
    /// A consequence of that ordering: the popup for `state`'s own change (open, close, or
    /// switch) applied in this same call -- from a click resolved below, or from
    /// [`MenuBar::handle_event`] called earlier this frame -- isn't drawn until the *next*
    /// frame's `show`, since this call already decided what to draw from `state.open_label()`
    /// before that change happens. One extra frame on top of the usual one-frame click-latency
    /// this crate already has everywhere (see [`Interaction`](crate::interact::Interaction)),
    /// not a bug: correctly prioritizing the bar over the popup's own backdrop for every
    /// *subsequent* frame (so a click or hover can still reach and switch/close it) requires
    /// registering the bar after the popup, which is exactly what makes the fresh transition
    /// itself one frame slower to render.
    ///
    /// `popup_size` is used for every label's popup; `bar_area` anchors each label's own popup
    /// [`Side::Below`] itself via [`anchored_rect`], clamped to `screen`.
    ///
    /// Returns `Some((label, row))` for the one frame a selectable row in `label`'s popup is
    /// clicked, the same "checkbox items fire and close" contract [`Menu::show`] has.
    #[must_use]
    pub fn show<Id: Copy + PartialEq>(
        &self,
        ui: &mut Ui<'_, '_, Id>,
        screen: Rect,
        bar_area: Rect,
        popup_size: Size,
        ids: MenuBarIds<Id>,
        state: &mut MenuBarState,
    ) -> Option<(usize, usize)> {
        let mut activated = None;

        if let Some(open) = state.open_label() {
            let anchor = self.label_rect(bar_area, open);
            let popup_area = anchored_rect(anchor, popup_size, Side::Below, screen);
            if let Some(row) = Menu::new(self.rows[open]).theme(self.theme).show(
                ui,
                screen,
                popup_area,
                ids.popup_content,
                ids.popup_backdrop,
                state.menu_mut(),
            ) {
                activated = Some((open, row));
            }
        }

        let (response, mut surface) = ui.region(bar_area, ids.label, Sense::click() | Sense::HOVER);
        self.draw(&mut surface, state.open_label());

        let hovered_label = response
            .pointer_pos()
            .and_then(|pos| self.label_at(bar_area, pos));

        if response.clicked()
            && let Some(label) = hovered_label
        {
            let (len, selectable) = self.rows_of(label);
            state.toggle(label, len, selectable);
        }

        match hovered_label {
            Some(label) => {
                let (len, selectable) = self.rows_of(label);
                state.hover(Some(label), len, selectable);
            }
            None => state.hover(None, 0, |_| false),
        }

        activated
    }

    /// Apply one keyboard event to `state`: Left/Right move which label is open, clamped at the
    /// ends (see the type docs); every other event forwards to the open label's own
    /// [`MenuState::handle_event`](crate::state::MenuState::handle_event), returning
    /// `Some((label, row))` for the one frame Enter activates a row.
    ///
    /// A no-op, on every key, while nothing is open: Left/Right don't open a label from cold any
    /// more than a hover does (see the type docs), and there is no open label's `MenuState` to
    /// forward anything else to.
    pub fn handle_event(&self, event: &Event, state: &mut MenuBarState) -> Option<(usize, usize)> {
        let open = state.open_label()?;

        if let Event::Key(key) = event
            && key.is_down()
        {
            let delta = match key.code {
                KeyCode::Left => -1,
                KeyCode::Right => 1,
                _ => 0,
            };
            if delta != 0 {
                self.step_open_label(state, open, delta);
                return None;
            }
        }

        let (len, selectable) = self.rows_of(open);
        let row = state.menu_mut().handle_event(event, len, selectable)?;
        Some((open, row))
    }

    /// Move the open label by `delta` (`1` or `-1`), clamped to `0..titles.len()`, reopening the
    /// new label's popup. A no-op if that clamps back to `current` (already at an end) or there
    /// are no labels at all.
    fn step_open_label(&self, state: &mut MenuBarState, current: usize, delta: i32) {
        let Ok(len) = i32::try_from(self.titles.len()) else {
            return; // absurdly large `titles`; leave the open label alone
        };
        if len == 0 {
            return;
        }
        let Ok(current_i32) = i32::try_from(current) else {
            return;
        };
        let next = (current_i32 + delta).clamp(0, len - 1);
        // `0 <= next < len`, and `len` came from a `usize` above, so this fits a `usize`.
        #[allow(clippy::cast_sign_loss)]
        let next = next as usize;
        if next != current {
            let (len, selectable) = self.rows_of(next);
            state.open(next, len, selectable);
        }
    }
}

#[cfg(test)]
mod tests {
    use retroglyph_core::event::{
        Event, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
    };
    use retroglyph_core::grid::Grid;

    use super::*;
    use crate::interact::Interaction;
    use crate::widget::Marker;

    const FILE_ROWS: [MenuRow<'static>; 2] = [
        MenuRow::Item {
            label: "New",
            marker: Marker::None,
            enabled: true,
        },
        MenuRow::Item {
            label: "Open",
            marker: Marker::None,
            enabled: true,
        },
    ];
    const EDIT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
        label: "Cut",
        marker: Marker::None,
        enabled: true,
    }];
    const VIEW_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
        label: "Zoom",
        marker: Marker::None,
        enabled: true,
    }];

    const TITLES: [&str; 3] = ["File", "Edit", "View"];

    fn rows() -> [&'static [MenuRow<'static>]; 3] {
        [&FILE_ROWS, &EDIT_ROWS, &VIEW_ROWS]
    }

    #[derive(Clone, Copy, PartialEq, Eq)]
    enum Id {
        Bar,
        PopupContent,
        PopupBackdrop,
    }

    fn show(
        bar: &MenuBar<'_>,
        interaction: &mut Interaction<Id>,
        grid: &mut Grid,
        screen: Rect,
        state: &mut MenuBarState,
    ) -> Option<(usize, usize)> {
        interaction.frame(&mut Surface::new(grid, screen, 0), |ui| {
            bar.show(
                ui,
                screen,
                Rect::new(0, 0, 20, 1),
                Size::new(10, 4),
                MenuBarIds {
                    label: Id::Bar,
                    popup_content: Id::PopupContent,
                    popup_backdrop: Id::PopupBackdrop,
                },
                state,
            )
        })
    }

    fn click(interaction: &mut Interaction<Id>, pos: Pos) {
        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
            MouseEventKind::Down(MouseButton::Left),
            pos,
            KeyModifiers::NONE,
        )));
        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
            MouseEventKind::Up(MouseButton::Left),
            pos,
            KeyModifiers::NONE,
        )));
    }

    fn hover(interaction: &mut Interaction<Id>, pos: Pos) {
        let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
            MouseEventKind::Moved,
            pos,
            KeyModifiers::NONE,
        )));
    }

    #[test]
    fn draws_every_title_left_to_right() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let area = Rect::new(0, 0, 20, 1);
        let mut grid = Grid::new(20, 1);
        bar.draw(&mut Surface::new(&mut grid, area, 0), None);

        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'F'); // "File"
        // "File" (4) + column_spacing (1) = "Edit" starts at column 5.
        assert_eq!(grid[Pos::new(5, 0)].glyph(), 'E');
    }

    #[test]
    fn cold_hover_does_not_open_anything() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        hover(&mut interaction, Pos::new(0, 0)); // over "File", but never clicked

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(state.open_label(), None);
    }

    #[test]
    fn click_opens_the_clicked_label() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        click(&mut interaction, Pos::new(0, 0)); // over "File"

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(state.open_label(), Some(0));
    }

    /// Mirrors `Tabs`' `tabs_wide_title_does_not_overlap_the_next_tab`: a wide-glyph label must
    /// not shadow the next label's own hit-test range.
    #[test]
    fn click_past_a_wide_glyph_label_opens_the_next_label() {
        const WIDE_TITLES: [&str; 2] = ["設定", "次"];
        const NEXT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
            label: "Item",
            marker: Marker::None,
            enabled: true,
        }];
        let wide_rows: [&[MenuRow<'static>]; 2] = [&NEXT_ROWS, &NEXT_ROWS];
        let bar = MenuBar::new(&WIDE_TITLES, &wide_rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        // "設定" occupies columns [0, 4); "次" starts at column 5 (4 + column_spacing 1).
        click(&mut interaction, Pos::new(5, 0));

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(state.open_label(), Some(1));
    }

    #[test]
    fn click_again_closes_the_same_label() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        click(&mut interaction, Pos::new(0, 0)); // over "File" again

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(state.open_label(), None);
    }

    #[test]
    fn hovering_another_label_while_one_is_open_switches_to_it() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        hover(&mut interaction, Pos::new(5, 0)); // over "Edit", no click

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(state.open_label(), Some(1));
    }

    #[test]
    fn clicking_a_row_activates_it_and_closes_the_bar() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        // Frame 1 registers the open popup's rows.
        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        // "File"'s popup is anchored below `bar_area` (y=1), "Open" is its second row (y=2).
        click(&mut interaction, Pos::new(1, 2));

        let mut grid = Grid::new(20, 10);
        let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(activated, Some((0, 1)));
        assert_eq!(state.open_label(), None);
    }

    #[test]
    fn clicking_outside_the_bar_and_popup_closes_without_activating() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        click(&mut interaction, Pos::new(19, 9)); // far outside both the bar and the popup

        let mut grid = Grid::new(20, 10);
        let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);

        assert_eq!(activated, None);
        assert_eq!(state.open_label(), None);
    }

    #[test]
    fn right_arrow_moves_to_the_next_label() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        let activated = bar.handle_event(&right, &mut state);

        assert_eq!(activated, None);
        assert_eq!(state.open_label(), Some(1));
    }

    #[test]
    fn left_right_clamp_at_the_ends_without_wrapping() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        let left = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        bar.handle_event(&left, &mut state);
        assert_eq!(state.open_label(), Some(0)); // clamped: already at the first label
    }

    #[test]
    fn left_right_are_a_no_op_while_nothing_is_open() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut state = MenuBarState::new();

        let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        bar.handle_event(&right, &mut state);
        assert_eq!(state.open_label(), None); // no cold-open, matching a cold hover
    }

    #[test]
    fn arrow_down_then_enter_navigates_and_activates_the_open_popup() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut state = MenuBarState::new();
        state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());

        let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        bar.handle_event(&down, &mut state);
        assert_eq!(state.menu().selected(), Some(1)); // moved from "New" to "Open"

        let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        let activated = bar.handle_event(&enter, &mut state);
        assert_eq!(activated, Some((0, 1)));
        assert_eq!(state.open_label(), None);
    }

    /// A pointer that never moves off the label it opened must not immediately undo a
    /// keyboard-driven `handle_event` switch to a different label: re-resolving "is the pointer
    /// over a different label than what's open" fresh every frame (rather than only on a
    /// genuinely new hover) would otherwise snap `open_label` straight back the very next frame
    /// `show` runs, since the stale, unmoved pointer position still resolves over the label that
    /// was open *before* the keypress.
    #[test]
    fn a_stationary_pointer_does_not_fight_a_keyboard_driven_switch() {
        let rows = rows();
        let bar = MenuBar::new(&TITLES, &rows);
        let mut interaction = Interaction::<Id>::new();
        let screen = Rect::new(0, 0, 20, 10);
        let mut state = MenuBarState::new();

        // Frame 1: register the bar's hitbox, with the pointer already resolved over "File".
        hover(&mut interaction, Pos::new(0, 0));
        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);

        // Click "File" open; the pointer stays at the same position throughout (no further
        // `hover` call).
        click(&mut interaction, Pos::new(0, 0));
        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);
        assert_eq!(state.open_label(), Some(0));

        // Keyboard-driven switch to "Edit", with the pointer still resting over "File".
        let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        bar.handle_event(&right, &mut state);
        assert_eq!(state.open_label(), Some(1));

        // The next `show` must not resolve the still-stationary pointer over "File" as a fresh
        // hover and switch back to it.
        let mut grid = Grid::new(20, 10);
        show(&bar, &mut interaction, &mut grid, screen, &mut state);
        assert_eq!(state.open_label(), Some(1));
    }
}