Skip to main content

qframe/widgets/
menu.rs

1//! Navigation menus for application sidebars.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::Key;
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::cells;
12use super::popup_menu::type_ahead;
13use super::row::{self, LEAD};
14use super::rows::{self, RowScroll};
15
16/// Builds a message from an item key.
17type KeyMessage<Msg> = Box<dyn Fn(&str) -> Msg>;
18
19/// Builds a message from a group key and whether the group should be open.
20type GroupMessage<Msg> = Box<dyn Fn(&str, bool) -> Msg>;
21
22/// One destination in a [`Menu`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct MenuItem {
25    key: String,
26    label: String,
27    icon: Option<(String, Option<String>)>,
28    badge: Option<String>,
29}
30
31impl MenuItem {
32    /// An item the application knows as `key`, showing `label`.
33    #[must_use]
34    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
35        Self { key: key.into(), label: label.into(), icon: None, badge: None }
36    }
37
38    /// Icon key drawn before the label, optionally in theme colour `color`.
39    #[must_use]
40    pub fn icon(mut self, key: impl Into<String>, color: Option<&str>) -> Self {
41        self.icon = Some((key.into(), color.map(str::to_owned)));
42        self
43    }
44
45    /// Short faint text at the right, such as a count of unread entries.
46    #[must_use]
47    pub fn badge(mut self, text: impl Into<String>) -> Self {
48        self.badge = Some(text.into());
49        self
50    }
51}
52
53/// A group of items in a [`Menu`], with an optional faint heading.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct MenuGroup {
56    key: String,
57    title: Option<String>,
58    items: Vec<MenuItem>,
59}
60
61impl MenuGroup {
62    /// A group the application knows as `key`, holding `items`.
63    #[must_use]
64    pub fn new(key: impl Into<String>, items: impl IntoIterator<Item = MenuItem>) -> Self {
65        Self { key: key.into(), title: None, items: items.into_iter().collect() }
66    }
67
68    /// The faint heading above the items; a collapsible menu toggles the group from it.
69    #[must_use]
70    pub fn title(mut self, title: impl Into<String>) -> Self {
71        self.title = Some(title.into());
72        self
73    }
74}
75
76/// A row of the flattened menu.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Row {
79    Gap,
80    Heading(usize),
81    Item(usize, usize),
82}
83
84#[derive(Debug, Default)]
85struct MenuMemory {
86    cursor: Option<Row>,
87    followed: Option<Row>,
88    flashed: Option<Row>,
89    /// Where the pointer was in the last frame; moving it carries the cursor.
90    pointer: Option<(i32, i32)>,
91}
92
93/// The navigation column of an application: groups of destinations under faint headings.
94///
95/// With no options it is the plainest menu: the selected item is raised with the accent
96/// pillar, a hovered item rises softly, and the icon and label of both slide one cell while
97/// badges stay anchored. It is usually the content of an [`AppShell`](super::AppShell) sidebar.
98///
99/// Keys while focused move a cursor, drawn like hover, without leaving the current page: ↑/↓,
100/// Home/End, and typing a letter jumps to the next item starting with it. Enter or Space opens
101/// the item under the cursor; a click opens at once. The application owns the selection. There
102/// is only ever one such highlight: moving the pointer onto a row moves the cursor there, and
103/// the keyboard continues from it.
104///
105/// [`collapsible`](Self::collapsible) lets groups with a title fold: the heading gets a chevron,
106/// a click or Enter on it toggles, → opens and ← closes it, and ← on an item goes to its heading.
107/// A foldable heading rises and slides its title like an item; its chevron stays anchored.
108///
109/// Style keys: `menu-item` with `hover`, `selected`, `focus`, `pressed`; `menu-badge` with the
110/// same states; `menu-heading` with `hover`; `scrollbar`.
111pub struct Menu<Msg> {
112    groups: Vec<MenuGroup>,
113    selected: Option<String>,
114    collapsed: Vec<String>,
115    on_select: Option<KeyMessage<Msg>>,
116    on_toggle: Option<GroupMessage<Msg>>,
117}
118
119impl<Msg: 'static> Menu<Msg> {
120    /// A menu of `groups`.
121    #[must_use]
122    pub fn new(groups: impl IntoIterator<Item = MenuGroup>) -> Self {
123        Self {
124            groups: groups.into_iter().collect(),
125            selected: None,
126            collapsed: Vec::new(),
127            on_select: None,
128            on_toggle: None,
129        }
130    }
131
132    /// The key of the current item.
133    #[must_use]
134    pub fn selected(mut self, key: Option<&str>) -> Self {
135        self.selected = key.map(str::to_owned);
136        self
137    }
138
139    /// Message for opening the item with `key`.
140    #[must_use]
141    pub fn on_select(mut self, message: impl Fn(&str) -> Msg + 'static) -> Self {
142        self.on_select = Some(Box::new(message));
143        self
144    }
145
146    /// Lets titled groups fold: `message(group, open)` asks the application to open or close a
147    /// group. Pass the closed groups with [`collapsed`](Self::collapsed).
148    #[must_use]
149    pub fn collapsible(mut self, message: impl Fn(&str, bool) -> Msg + 'static) -> Self {
150        self.on_toggle = Some(Box::new(message));
151        self
152    }
153
154    /// Keys of the groups that are closed in a collapsible menu.
155    #[must_use]
156    pub fn collapsed(mut self, groups: impl IntoIterator<Item = impl Into<String>>) -> Self {
157        self.collapsed = groups.into_iter().map(Into::into).collect();
158        self
159    }
160
161    fn is_closed(&self, group: usize) -> bool {
162        self.on_toggle.is_some() && self.collapsed.contains(&self.groups[group].key)
163    }
164
165    fn rows(&self) -> Vec<Row> {
166        let mut rows = Vec::new();
167        for (index, group) in self.groups.iter().enumerate() {
168            if index > 0 {
169                rows.push(Row::Gap);
170            }
171            if group.title.is_some() {
172                rows.push(Row::Heading(index));
173            }
174            if !self.is_closed(index) {
175                rows.extend((0..group.items.len()).map(|item| Row::Item(index, item)));
176            }
177        }
178        rows
179    }
180
181    /// Whether the keyboard cursor can rest on `row`.
182    fn navigable(&self, row: Row) -> bool {
183        match row {
184            Row::Gap => false,
185            Row::Heading(_) => self.on_toggle.is_some(),
186            Row::Item(..) => true,
187        }
188    }
189
190    fn selected_row(&self) -> Option<Row> {
191        let key = self.selected.as_deref()?;
192        self.groups
193            .iter()
194            .enumerate()
195            .find_map(|(g, group)| group.items.iter().position(|item| item.key == key).map(|i| Row::Item(g, i)))
196    }
197
198    /// The cursor in `rows`: the remembered one while it is visible, else the selection, else
199    /// the first navigable row.
200    fn cursor(&self, remembered: Option<Row>, rows: &[Row]) -> Option<usize> {
201        let find = |row: Option<Row>| row.and_then(|row| rows.iter().position(|r| *r == row));
202        find(remembered)
203            .or_else(|| find(self.selected_row()))
204            .or_else(|| rows.iter().position(|row| self.navigable(*row)))
205    }
206
207    fn item(&self, group: usize, item: usize) -> &MenuItem {
208        &self.groups[group].items[item]
209    }
210
211    fn open(&self, cx: &mut EventCx<'_, Msg>, row: Row) {
212        match row {
213            Row::Item(group, item) => {
214                let key = &self.item(group, item).key;
215                if let Some(message) = &self.on_select {
216                    cx.memory::<MenuMemory>().flashed = Some(row);
217                    cx.flash();
218                    if self.selected.as_deref() != Some(key) {
219                        cx.emit(message(key));
220                    }
221                }
222            }
223            Row::Heading(group) => self.toggle(cx, group, self.is_closed(group)),
224            Row::Gap => {}
225        }
226    }
227
228    fn toggle(&self, cx: &mut EventCx<'_, Msg>, group: usize, open: bool) {
229        if let Some(message) = &self.on_toggle
230            && open == self.is_closed(group)
231        {
232            cx.emit(message(&self.groups[group].key, open));
233        }
234    }
235
236    fn step(&self, rows: &[Row], from: usize, forward: bool) -> usize {
237        let mut index = from;
238        loop {
239            let next = if forward { index + 1 } else { index.wrapping_sub(1) };
240            match rows.get(next) {
241                Some(row) if self.navigable(*row) => return next,
242                Some(_) => index = next,
243                None => return from,
244            }
245        }
246    }
247}
248
249impl<Msg: 'static> Widget<Msg> for Menu<Msg> {
250    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
251        let rows = self.rows();
252        let widest = rows
253            .iter()
254            .map(|row| match *row {
255                Row::Gap => 0,
256                Row::Heading(group) => {
257                    cells::sum([text::width(self.groups[group].title.as_deref().unwrap_or_default()), LEAD, 3])
258                }
259                Row::Item(group, item) => {
260                    let item = self.item(group, item);
261                    cells::sum([
262                        LEAD,
263                        text::width(&item.label),
264                        item.icon.as_ref().map_or(0, |_| 2),
265                        item.badge.as_deref().map_or(0, |badge| text::width(badge).saturating_add(2)),
266                        3,
267                    ])
268                }
269            })
270            .max()
271            .unwrap_or(0);
272        Size::new(widest, clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX))).min(available)
273    }
274
275    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
276        cx.register_hit(area);
277        let rows = self.rows();
278        if rows.is_empty() || area.is_empty() {
279            return;
280        }
281        let focused = cx.is_focused();
282        let pressed = cx.is_pressed();
283        let pointer = cx.pointer();
284        let slide = cx.env().slide();
285        let visible = usize::from(area.height);
286        // The first visible row and the scrollbar drag live in the shared row scroll state.
287        let mut offset = cx.memory::<RowScroll>().offset;
288        let (cursor, flashed) = {
289            let memory = cx.memory::<MenuMemory>();
290            // The pointer moves the one highlight: a row it moves onto becomes the cursor, so the
291            // keyboard continues from there and never a second row is raised.
292            if pointer != memory.pointer {
293                memory.pointer = pointer;
294                let bar = rows.len() > visible && pointer.is_some_and(|(x, _)| x == area.right() - 1);
295                let under = pointer
296                    .filter(|_| !bar)
297                    .and_then(|(_, y)| usize::try_from(y - area.y).ok())
298                    .and_then(|line| rows.get(offset + line))
299                    .filter(|row| self.navigable(**row));
300                if let Some(row) = under {
301                    memory.cursor = Some(*row);
302                }
303            }
304            let cursor = self.cursor(memory.cursor, &rows);
305            let follow = if focused { cursor.map(|c| rows[c]) } else { self.selected_row() };
306            if follow != memory.followed {
307                if let Some(index) = follow.and_then(|row| rows.iter().position(|r| *r == row)) {
308                    if index < offset {
309                        offset = index;
310                    } else if index >= offset + visible {
311                        offset = index + 1 - visible;
312                    }
313                }
314                memory.followed = follow;
315            }
316            (cursor, memory.flashed)
317        };
318        offset = offset.min(rows.len().saturating_sub(visible));
319        cx.memory::<RowScroll>().offset = offset;
320        let width = if rows.len() > visible { area.width.saturating_sub(1) } else { area.width };
321        let selected = self.selected_row();
322
323        for (line, index) in (offset..rows.len()).take(visible).enumerate() {
324            let rect = Rect::new(area.x, area.y + i32::try_from(line).unwrap_or(0), width, 1);
325            let row = rows[index];
326            let mut states = Vec::new();
327            let pointed = pointer.is_some_and(|(x, y)| rect.contains(x, y));
328            let lit = if focused { cursor == Some(index) } else { pointed };
329            if self.navigable(row) && lit {
330                states.push(State::Hover);
331            }
332            match row {
333                Row::Gap => {}
334                Row::Heading(group) => {
335                    let style = cx.style("menu-heading", None, &states);
336                    let title = self.groups[group].title.as_deref().unwrap_or_default();
337                    // A foldable heading is a row like any other: it rises and its title slides,
338                    // while the chevron at the right stays anchored.
339                    let chevron = self.on_toggle.is_some().then(|| {
340                        let key = if self.is_closed(group) { "chevron-right" } else { "chevron-down" };
341                        text::truncate(&cx.env().icons().glyph(key), 1).into_owned()
342                    });
343                    let trailing = if chevron.is_some() { 2 } else { 0 };
344                    let raised = states.contains(&State::Hover);
345                    row::paint(cx, rect, &style, slide && raised, &[], title, trailing);
346                    if let Some(glyph) = chevron {
347                        let plain = CellStyle { bg: None, ..style.text() };
348                        cx.text(rect.right() - 2, rect.y, &glyph, plain, 1);
349                    }
350                }
351                Row::Item(group, item_index) => {
352                    let item = self.item(group, item_index);
353                    if selected == Some(row) {
354                        states.push(State::Selected);
355                        if focused {
356                            states.push(State::Focus);
357                        }
358                    }
359                    if pressed && flashed == Some(row) {
360                        states.push(State::Pressed);
361                    }
362                    let style = cx.style("menu-item", None, &states);
363                    let text_style = style.text();
364                    let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
365                    let badge_width = item.badge.as_deref().map_or(0, |badge| text::width(badge).saturating_add(2));
366                    let marks: Vec<row::Mark> = item
367                        .icon
368                        .iter()
369                        .map(|(key, color)| row::icon(cx, key, color.as_deref(), text_style.fg))
370                        .collect();
371                    row::paint(cx, rect, &style, slide && raised, &marks, &item.label, badge_width);
372                    if let Some(badge) = &item.badge {
373                        let badge_style = cx.style("menu-badge", None, &states).text();
374                        row::paint_trailing(cx, rect, badge, CellStyle { bg: None, ..badge_style });
375                    }
376                }
377            }
378        }
379        rows::paint_scrollbar(cx, area, rows.len(), offset, None);
380    }
381
382    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
383        let rows = self.rows();
384        if rows.is_empty() {
385            return false;
386        }
387        let area = cx.area();
388        match event {
389            Event::Key(key) => {
390                let remembered = cx.memory::<MenuMemory>().cursor;
391                let Some(cursor) = self.cursor(remembered, &rows) else {
392                    return false;
393                };
394                let row = rows[cursor];
395                let target = if key.is_plain(Key::Up) {
396                    self.step(&rows, cursor, false)
397                } else if key.is_plain(Key::Down) {
398                    self.step(&rows, cursor, true)
399                } else if key.is_plain(Key::Home) {
400                    rows.iter().position(|r| self.navigable(*r)).unwrap_or(cursor)
401                } else if key.is_plain(Key::End) {
402                    rows.iter().rposition(|r| self.navigable(*r)).unwrap_or(cursor)
403                } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
404                    self.open(cx, row);
405                    return true;
406                } else if key.is_plain(Key::Right) {
407                    let Row::Heading(group) = row else { return false };
408                    self.toggle(cx, group, true);
409                    return self.on_toggle.is_some();
410                } else if key.is_plain(Key::Left) {
411                    match row {
412                        Row::Heading(group) => self.toggle(cx, group, false),
413                        Row::Item(group, _) if self.on_toggle.is_some() && self.groups[group].title.is_some() => {
414                            cx.memory::<MenuMemory>().cursor = Some(Row::Heading(group));
415                        }
416                        _ => return false,
417                    }
418                    return self.on_toggle.is_some();
419                } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt) {
420                    let items: Vec<usize> = (0..rows.len()).filter(|i| matches!(rows[*i], Row::Item(..))).collect();
421                    let labels: Vec<String> = items
422                        .iter()
423                        .map(|i| match rows[*i] {
424                            Row::Item(g, it) => self.item(g, it).label.clone(),
425                            _ => String::new(),
426                        })
427                        .collect();
428                    let from = items.iter().rposition(|i| *i <= cursor).unwrap_or(items.len().saturating_sub(1));
429                    match type_ahead(&labels, from, typed) {
430                        Some(found) => items[found],
431                        None => return !labels.is_empty(),
432                    }
433                } else {
434                    return false;
435                };
436                cx.memory::<MenuMemory>().cursor = Some(rows[target]);
437                true
438            }
439            Event::Mouse(mouse) => {
440                // The wheel scrolls, and the scrollbar is dragged like a list's.
441                if rows::scroll_mouse(cx, mouse, area, rows.len()) {
442                    return true;
443                }
444                let offset = cx.memory::<RowScroll>().offset;
445                match mouse.kind {
446                    MouseKind::Down(MouseButton::Left) => {
447                        let index = usize::try_from(mouse.y - area.y).ok().map(|line| offset + line);
448                        let Some(row) = index.and_then(|i| rows.get(i).copied()).filter(|r| self.navigable(*r)) else {
449                            return false;
450                        };
451                        cx.memory::<MenuMemory>().cursor = Some(row);
452                        self.open(cx, row);
453                        true
454                    }
455                    _ => false,
456                }
457            }
458            _ => false,
459        }
460    }
461
462    fn focusable(&self) -> bool {
463        self.groups.iter().any(|group| !group.items.is_empty())
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::runtime::{App, Command, Harness};
471    use crate::widget::View;
472
473    struct Sidebar {
474        page: String,
475        closed: Vec<String>,
476        collapsible: bool,
477    }
478
479    #[derive(Debug, Clone)]
480    enum Msg {
481        Go(String),
482        Group(String, bool),
483    }
484
485    impl App for Sidebar {
486        type Msg = Msg;
487        fn update(&mut self, msg: Msg) -> Command<Msg> {
488            match msg {
489                Msg::Go(page) => self.page = page,
490                Msg::Group(group, open) => {
491                    self.closed.retain(|g| *g != group);
492                    if !open {
493                        self.closed.push(group);
494                    }
495                }
496            }
497            Command::none()
498        }
499        fn view(&self, ui: &mut View<'_, Msg>) {
500            let groups = [
501                MenuGroup::new(
502                    "work",
503                    [MenuItem::new("overview", "Overview"), MenuItem::new("deploys", "Deploys").badge("3")],
504                )
505                .title("WORKSPACE"),
506                MenuGroup::new(
507                    "infra",
508                    [
509                        MenuItem::new("containers", "Containers").icon("dot", Some("success")),
510                        MenuItem::new("volumes", "Volumes"),
511                    ],
512                )
513                .title("INFRASTRUCTURE"),
514            ];
515            let mut menu = Menu::new(groups).selected(Some(&self.page)).on_select(|key| Msg::Go(key.to_owned()));
516            if self.collapsible {
517                menu =
518                    menu.collapsible(|group, open| Msg::Group(group.to_owned(), open)).collapsed(self.closed.clone());
519            }
520            ui.add(menu).fill().id("menu");
521        }
522    }
523
524    fn sidebar(collapsible: bool) -> Sidebar {
525        Sidebar { page: "overview".into(), closed: Vec::new(), collapsible }
526    }
527
528    #[test]
529    fn groups_headings_badges_and_selection() {
530        let h = Harness::new(sidebar(false), 24, 7);
531        assert_eq!(
532            h.screen(),
533            "  WORKSPACE\n▌  Overview\n  Deploys             3\n\n  INFRASTRUCTURE\n  ● Containers\n  Volumes\n"
534        );
535        assert_eq!(h.bg(5, 1), h.env().theme().color("active"));
536    }
537
538    #[test]
539    fn keyboard_moves_a_cursor_and_enter_opens() {
540        let mut h = Harness::new(sidebar(false), 24, 7);
541        h.press("tab").press("down");
542        assert_eq!(h.app().page, "overview", "moving does not navigate");
543        assert!(h.screen().contains("▌  Deploys            3"), "{}", h.screen());
544        h.press("down").press("enter");
545        assert_eq!(h.app().page, "containers");
546        h.press("v");
547        h.press("space");
548        assert_eq!(h.app().page, "volumes");
549        h.press("home").press("enter");
550        assert_eq!(h.app().page, "overview");
551        h.click_text("Deploys");
552        assert_eq!(h.app().page, "deploys");
553    }
554
555    #[test]
556    fn the_scrollbar_is_dragged_and_never_opens_the_row_beside_it() {
557        let mut h = Harness::new(sidebar(false), 24, 4);
558        assert!(h.screen().starts_with("  WORKSPACE"), "{}", h.screen());
559        h.mouse(MouseKind::Down(MouseButton::Left), 23, 2);
560        h.mouse(MouseKind::Drag(MouseButton::Left), 23, 3);
561        h.mouse(MouseKind::Up(MouseButton::Left), 23, 3);
562        assert_eq!(h.app().page, "overview", "a press on the scrollbar opens nothing");
563        let screen = h.screen();
564        assert!(screen.contains("Volumes") && !screen.contains("WORKSPACE"), "dragged to the end:\n{screen}");
565        h.mouse(MouseKind::ScrollUp, 5, 1);
566        assert!(h.screen().starts_with("  WORKSPACE"), "{}", h.screen());
567    }
568
569    #[test]
570    fn the_pointer_carries_the_one_highlight() {
571        let mut h = Harness::new(sidebar(false), 24, 7);
572        h.press("tab").press("down");
573        assert!(h.screen().contains("▌  Deploys"), "{}", h.screen());
574        let (x, y) = h.find("Volumes").expect("volumes row");
575        h.hover(x + 2, y);
576        let screen = h.screen();
577        assert_eq!(
578            screen,
579            "  WORKSPACE\n▌  Overview\n  Deploys             3\n\n  INFRASTRUCTURE\n  ● Containers\n▌  Volumes\n",
580            "the keyboard's row gives way to the pointer's"
581        );
582        h.press("up");
583        let screen = h.screen();
584        assert!(screen.contains("▌  ● Containers") && screen.contains("\n  Volumes"), "{screen}");
585        h.press("enter");
586        assert_eq!(h.app().page, "containers", "the keyboard continued from the pointer's row");
587    }
588
589    #[test]
590    fn a_foldable_heading_rises_and_slides_while_its_chevron_stays() {
591        let mut h = Harness::new(sidebar(true), 24, 7);
592        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
593        h.hover(6, 0);
594        assert_eq!(h.screen().lines().next(), Some("▌  WORKSPACE          ▾"), "{}", h.screen());
595        assert_eq!(h.bg(10, 0), h.env().theme().color("raised"));
596        let mut env = crate::env::Env::builtin();
597        env.set_slide(false);
598        let mut h = Harness::with_env(sidebar(true), env, 24, 7);
599        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
600        h.hover(6, 0);
601        assert_eq!(h.screen().lines().next(), Some("▌ WORKSPACE           ▾"), "{}", h.screen());
602        let mut plain = Harness::new(sidebar(false), 24, 7);
603        plain.hover(6, 0);
604        assert_eq!(plain.screen().lines().next(), Some("  WORKSPACE"), "a heading that cannot fold never rises");
605    }
606
607    #[test]
608    fn collapsible_groups_fold_by_click_and_arrows() {
609        let mut h = Harness::new(sidebar(true), 24, 7);
610        assert!(h.screen().starts_with("  WORKSPACE           ▾\n"), "{}", h.screen());
611        h.click_text("INFRASTRUCTURE");
612        assert_eq!(h.app().closed, vec!["infra".to_owned()]);
613        assert!(!h.screen().contains("Volumes") && h.screen().contains('▶'), "{}", h.screen());
614        h.press("home").press("down").press("left");
615        assert_eq!(h.app().closed, vec!["infra".to_owned()], "left on an item goes to its heading");
616        h.press("left");
617        assert!(h.app().closed.contains(&"work".to_owned()), "{:?}", h.app().closed);
618        h.press("right");
619        assert!(!h.app().closed.contains(&"work".to_owned()));
620        let plain = Harness::new(sidebar(false), 24, 7);
621        assert!(!plain.screen().contains('▾'), "plain menus have no chevrons");
622    }
623}