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/// (round the ends with [`wrap`](Self::wrap)), 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    wrap: bool,
118}
119
120impl<Msg: 'static> Menu<Msg> {
121    /// A menu of `groups`.
122    #[must_use]
123    pub fn new(groups: impl IntoIterator<Item = MenuGroup>) -> Self {
124        Self {
125            groups: groups.into_iter().collect(),
126            selected: None,
127            collapsed: Vec::new(),
128            on_select: None,
129            on_toggle: None,
130            wrap: false,
131        }
132    }
133
134    /// Lets ↓ on the last row go on to the first and ↑ on the first go to the last. Home and End
135    /// still stop at the ends. Off by default.
136    #[must_use]
137    pub fn wrap(mut self, wrap: bool) -> Self {
138        self.wrap = wrap;
139        self
140    }
141
142    /// The key of the current item.
143    #[must_use]
144    pub fn selected(mut self, key: Option<&str>) -> Self {
145        self.selected = key.map(str::to_owned);
146        self
147    }
148
149    /// Message for opening the item with `key`.
150    #[must_use]
151    pub fn on_select(mut self, message: impl Fn(&str) -> Msg + 'static) -> Self {
152        self.on_select = Some(Box::new(message));
153        self
154    }
155
156    /// Lets titled groups fold: `message(group, open)` asks the application to open or close a
157    /// group. Pass the closed groups with [`collapsed`](Self::collapsed).
158    #[must_use]
159    pub fn collapsible(mut self, message: impl Fn(&str, bool) -> Msg + 'static) -> Self {
160        self.on_toggle = Some(Box::new(message));
161        self
162    }
163
164    /// Keys of the groups that are closed in a collapsible menu.
165    #[must_use]
166    pub fn collapsed(mut self, groups: impl IntoIterator<Item = impl Into<String>>) -> Self {
167        self.collapsed = groups.into_iter().map(Into::into).collect();
168        self
169    }
170
171    fn is_closed(&self, group: usize) -> bool {
172        self.on_toggle.is_some() && self.collapsed.contains(&self.groups[group].key)
173    }
174
175    fn rows(&self) -> Vec<Row> {
176        let mut rows = Vec::new();
177        for (index, group) in self.groups.iter().enumerate() {
178            if index > 0 {
179                rows.push(Row::Gap);
180            }
181            if group.title.is_some() {
182                rows.push(Row::Heading(index));
183            }
184            if !self.is_closed(index) {
185                rows.extend((0..group.items.len()).map(|item| Row::Item(index, item)));
186            }
187        }
188        rows
189    }
190
191    /// Whether the keyboard cursor can rest on `row`.
192    fn navigable(&self, row: Row) -> bool {
193        match row {
194            Row::Gap => false,
195            Row::Heading(_) => self.on_toggle.is_some(),
196            Row::Item(..) => true,
197        }
198    }
199
200    fn selected_row(&self) -> Option<Row> {
201        let key = self.selected.as_deref()?;
202        self.groups
203            .iter()
204            .enumerate()
205            .find_map(|(g, group)| group.items.iter().position(|item| item.key == key).map(|i| Row::Item(g, i)))
206    }
207
208    /// The cursor in `rows`: the remembered one while it is visible, else the selection, else
209    /// the first navigable row.
210    fn cursor(&self, remembered: Option<Row>, rows: &[Row]) -> Option<usize> {
211        let find = |row: Option<Row>| row.and_then(|row| rows.iter().position(|r| *r == row));
212        find(remembered)
213            .or_else(|| find(self.selected_row()))
214            .or_else(|| rows.iter().position(|row| self.navigable(*row)))
215    }
216
217    fn item(&self, group: usize, item: usize) -> &MenuItem {
218        &self.groups[group].items[item]
219    }
220
221    fn open(&self, cx: &mut EventCx<'_, Msg>, row: Row) {
222        match row {
223            Row::Item(group, item) => {
224                let key = &self.item(group, item).key;
225                if let Some(message) = &self.on_select {
226                    cx.memory::<MenuMemory>().flashed = Some(row);
227                    cx.flash();
228                    if self.selected.as_deref() != Some(key) {
229                        cx.emit(message(key));
230                    }
231                }
232            }
233            Row::Heading(group) => self.toggle(cx, group, self.is_closed(group)),
234            Row::Gap => {}
235        }
236    }
237
238    fn toggle(&self, cx: &mut EventCx<'_, Msg>, group: usize, open: bool) {
239        if let Some(message) = &self.on_toggle
240            && open == self.is_closed(group)
241        {
242            cx.emit(message(&self.groups[group].key, open));
243        }
244    }
245
246    /// The row one step from `from`, going round the ends when the menu wraps.
247    fn neighbour(&self, rows: &[Row], from: usize, forward: bool) -> usize {
248        let next = self.step(rows, from, forward);
249        if !self.wrap || next != from {
250            return next;
251        }
252        let edge = if forward {
253            rows.iter().position(|r| self.navigable(*r))
254        } else {
255            rows.iter().rposition(|r| self.navigable(*r))
256        };
257        edge.unwrap_or(from)
258    }
259
260    fn step(&self, rows: &[Row], from: usize, forward: bool) -> usize {
261        let mut index = from;
262        loop {
263            let next = if forward { index + 1 } else { index.wrapping_sub(1) };
264            match rows.get(next) {
265                Some(row) if self.navigable(*row) => return next,
266                Some(_) => index = next,
267                None => return from,
268            }
269        }
270    }
271}
272
273impl<Msg: 'static> Widget<Msg> for Menu<Msg> {
274    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
275        let rows = self.rows();
276        let widest = rows
277            .iter()
278            .map(|row| match *row {
279                Row::Gap => 0,
280                Row::Heading(group) => {
281                    cells::sum([text::width(self.groups[group].title.as_deref().unwrap_or_default()), LEAD, 3])
282                }
283                Row::Item(group, item) => {
284                    let item = self.item(group, item);
285                    cells::sum([
286                        LEAD,
287                        text::width(&item.label),
288                        item.icon.as_ref().map_or(0, |_| 2),
289                        item.badge.as_deref().map_or(0, |badge| text::width(badge).saturating_add(2)),
290                        3,
291                    ])
292                }
293            })
294            .max()
295            .unwrap_or(0);
296        Size::new(widest, clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX))).min(available)
297    }
298
299    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
300        cx.register_hit(area);
301        let rows = self.rows();
302        if rows.is_empty() || area.is_empty() {
303            return;
304        }
305        let focused = cx.is_focused();
306        let pressed = cx.is_pressed();
307        let pointer = cx.pointer();
308        let slide = cx.env().slide();
309        let visible = usize::from(area.height);
310        // The first visible row and the scrollbar drag live in the shared row scroll state.
311        let mut offset = cx.memory::<RowScroll>().offset;
312        let (cursor, flashed) = {
313            let memory = cx.memory::<MenuMemory>();
314            // The pointer moves the one highlight: a row it moves onto becomes the cursor, so the
315            // keyboard continues from there and never a second row is raised.
316            if pointer != memory.pointer {
317                memory.pointer = pointer;
318                let bar = rows.len() > visible && pointer.is_some_and(|(x, _)| x == area.right() - 1);
319                let under = pointer
320                    .filter(|_| !bar)
321                    .and_then(|(_, y)| usize::try_from(y - area.y).ok())
322                    .and_then(|line| rows.get(offset + line))
323                    .filter(|row| self.navigable(**row));
324                if let Some(row) = under {
325                    memory.cursor = Some(*row);
326                }
327            }
328            let cursor = self.cursor(memory.cursor, &rows);
329            let follow = if focused { cursor.map(|c| rows[c]) } else { self.selected_row() };
330            if follow != memory.followed {
331                if let Some(index) = follow.and_then(|row| rows.iter().position(|r| *r == row)) {
332                    if index < offset {
333                        offset = index;
334                    } else if index >= offset + visible {
335                        offset = index + 1 - visible;
336                    }
337                }
338                memory.followed = follow;
339            }
340            (cursor, memory.flashed)
341        };
342        offset = offset.min(rows.len().saturating_sub(visible));
343        cx.memory::<RowScroll>().offset = offset;
344        let width = if rows.len() > visible { area.width.saturating_sub(1) } else { area.width };
345        let selected = self.selected_row();
346
347        for (line, index) in (offset..rows.len()).take(visible).enumerate() {
348            let rect = Rect::new(area.x, area.y + i32::try_from(line).unwrap_or(0), width, 1);
349            let row = rows[index];
350            let mut states = Vec::new();
351            let pointed = pointer.is_some_and(|(x, y)| rect.contains(x, y));
352            let lit = if focused { cursor == Some(index) } else { pointed };
353            if self.navigable(row) && lit {
354                states.push(State::Hover);
355            }
356            match row {
357                Row::Gap => {}
358                Row::Heading(group) => {
359                    let style = cx.style("menu-heading", None, &states);
360                    let title = self.groups[group].title.as_deref().unwrap_or_default();
361                    // A foldable heading is a row like any other: it rises and its title slides,
362                    // while the chevron at the right stays anchored.
363                    let chevron = self.on_toggle.is_some().then(|| {
364                        let key = if self.is_closed(group) { "chevron-right" } else { "chevron-down" };
365                        text::truncate(&cx.env().icons().glyph(key), 1).into_owned()
366                    });
367                    let trailing = if chevron.is_some() { 2 } else { 0 };
368                    let raised = states.contains(&State::Hover);
369                    row::paint(cx, rect, &style, slide && raised, &[], title, trailing);
370                    if let Some(glyph) = chevron {
371                        let plain = CellStyle { bg: None, ..style.text() };
372                        cx.text(rect.right() - 2, rect.y, &glyph, plain, 1);
373                    }
374                }
375                Row::Item(group, item_index) => {
376                    let item = self.item(group, item_index);
377                    if selected == Some(row) {
378                        states.push(State::Selected);
379                        if focused {
380                            states.push(State::Focus);
381                        }
382                    }
383                    if pressed && flashed == Some(row) {
384                        states.push(State::Pressed);
385                    }
386                    let style = cx.style("menu-item", None, &states);
387                    let text_style = style.text();
388                    let raised = states.contains(&State::Hover) || states.contains(&State::Selected);
389                    let badge_width = item.badge.as_deref().map_or(0, |badge| text::width(badge).saturating_add(2));
390                    let marks: Vec<row::Mark> = item
391                        .icon
392                        .iter()
393                        .map(|(key, color)| row::icon(cx, key, color.as_deref(), text_style.fg))
394                        .collect();
395                    row::paint(cx, rect, &style, slide && raised, &marks, &item.label, badge_width);
396                    if let Some(badge) = &item.badge {
397                        let badge_style = cx.style("menu-badge", None, &states).text();
398                        row::paint_trailing(cx, rect, badge, CellStyle { bg: None, ..badge_style });
399                    }
400                }
401            }
402        }
403        rows::paint_scrollbar(cx, area, rows.len(), offset, None);
404    }
405
406    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
407        let rows = self.rows();
408        if rows.is_empty() {
409            return false;
410        }
411        let area = cx.area();
412        match event {
413            Event::Key(key) => {
414                let remembered = cx.memory::<MenuMemory>().cursor;
415                let Some(cursor) = self.cursor(remembered, &rows) else {
416                    return false;
417                };
418                let row = rows[cursor];
419                let target = if key.is_plain(Key::Up) {
420                    self.neighbour(&rows, cursor, false)
421                } else if key.is_plain(Key::Down) {
422                    self.neighbour(&rows, cursor, true)
423                } else if key.is_plain(Key::Home) {
424                    rows.iter().position(|r| self.navigable(*r)).unwrap_or(cursor)
425                } else if key.is_plain(Key::End) {
426                    rows.iter().rposition(|r| self.navigable(*r)).unwrap_or(cursor)
427                } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
428                    self.open(cx, row);
429                    return true;
430                } else if key.is_plain(Key::Right) {
431                    let Row::Heading(group) = row else { return false };
432                    self.toggle(cx, group, true);
433                    return self.on_toggle.is_some();
434                } else if key.is_plain(Key::Left) {
435                    match row {
436                        Row::Heading(group) => self.toggle(cx, group, false),
437                        Row::Item(group, _) if self.on_toggle.is_some() && self.groups[group].title.is_some() => {
438                            cx.memory::<MenuMemory>().cursor = Some(Row::Heading(group));
439                        }
440                        _ => return false,
441                    }
442                    return self.on_toggle.is_some();
443                } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt) {
444                    let items: Vec<usize> = (0..rows.len()).filter(|i| matches!(rows[*i], Row::Item(..))).collect();
445                    let labels: Vec<String> = items
446                        .iter()
447                        .map(|i| match rows[*i] {
448                            Row::Item(g, it) => self.item(g, it).label.clone(),
449                            _ => String::new(),
450                        })
451                        .collect();
452                    let from = items.iter().rposition(|i| *i <= cursor).unwrap_or(items.len().saturating_sub(1));
453                    match type_ahead(&labels, from, typed) {
454                        Some(found) => items[found],
455                        None => return !labels.is_empty(),
456                    }
457                } else {
458                    return false;
459                };
460                cx.memory::<MenuMemory>().cursor = Some(rows[target]);
461                true
462            }
463            Event::Mouse(mouse) => {
464                // The wheel scrolls, and the scrollbar is dragged like a list's.
465                if rows::scroll_mouse(cx, mouse, area, rows.len()) {
466                    return true;
467                }
468                let offset = cx.memory::<RowScroll>().offset;
469                match mouse.kind {
470                    MouseKind::Down(MouseButton::Left) => {
471                        let index = usize::try_from(mouse.y - area.y).ok().map(|line| offset + line);
472                        let Some(row) = index.and_then(|i| rows.get(i).copied()).filter(|r| self.navigable(*r)) else {
473                            return false;
474                        };
475                        cx.memory::<MenuMemory>().cursor = Some(row);
476                        self.open(cx, row);
477                        true
478                    }
479                    _ => false,
480                }
481            }
482            _ => false,
483        }
484    }
485
486    fn focusable(&self) -> bool {
487        self.groups.iter().any(|group| !group.items.is_empty())
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::runtime::{App, Command, Harness};
495    use crate::widget::View;
496
497    struct Sidebar {
498        page: String,
499        closed: Vec<String>,
500        collapsible: bool,
501        wrap: bool,
502    }
503
504    #[derive(Debug, Clone)]
505    enum Msg {
506        Go(String),
507        Group(String, bool),
508    }
509
510    impl App for Sidebar {
511        type Msg = Msg;
512        fn update(&mut self, msg: Msg) -> Command<Msg> {
513            match msg {
514                Msg::Go(page) => self.page = page,
515                Msg::Group(group, open) => {
516                    self.closed.retain(|g| *g != group);
517                    if !open {
518                        self.closed.push(group);
519                    }
520                }
521            }
522            Command::none()
523        }
524        fn view(&self, ui: &mut View<'_, Msg>) {
525            let groups = [
526                MenuGroup::new(
527                    "work",
528                    [MenuItem::new("overview", "Overview"), MenuItem::new("deploys", "Deploys").badge("3")],
529                )
530                .title("WORKSPACE"),
531                MenuGroup::new(
532                    "infra",
533                    [
534                        MenuItem::new("containers", "Containers").icon("dot", Some("success")),
535                        MenuItem::new("volumes", "Volumes"),
536                    ],
537                )
538                .title("INFRASTRUCTURE"),
539            ];
540            let mut menu =
541                Menu::new(groups).selected(Some(&self.page)).wrap(self.wrap).on_select(|key| Msg::Go(key.to_owned()));
542            if self.collapsible {
543                menu =
544                    menu.collapsible(|group, open| Msg::Group(group.to_owned(), open)).collapsed(self.closed.clone());
545            }
546            ui.add(menu).fill().id("menu");
547        }
548    }
549
550    fn sidebar(collapsible: bool) -> Sidebar {
551        Sidebar { page: "overview".into(), closed: Vec::new(), collapsible, wrap: false }
552    }
553
554    #[test]
555    fn a_wrapping_menu_goes_round_its_ends_and_stops_otherwise() {
556        let mut h = Harness::new(Sidebar { wrap: true, ..sidebar(false) }, 30, 8);
557        h.press("tab").press("end").press("down").press("enter");
558        assert_eq!(h.app().page, "overview", "down on the last item reaches the first");
559        h.press("home").press("up").press("enter");
560        assert_eq!(h.app().page, "volumes", "up on the first item reaches the last");
561        let mut h = Harness::new(sidebar(false), 30, 8);
562        h.press("tab").press("end").press("down").press("enter");
563        assert_eq!(h.app().page, "volumes");
564    }
565
566    #[test]
567    fn groups_headings_badges_and_selection() {
568        let h = Harness::new(sidebar(false), 24, 7);
569        assert_eq!(
570            h.screen(),
571            "  WORKSPACE\n▌  Overview\n  Deploys             3\n\n  INFRASTRUCTURE\n  ● Containers\n  Volumes\n"
572        );
573        assert_eq!(h.bg(5, 1), h.env().theme().color("active"));
574    }
575
576    #[test]
577    fn keyboard_moves_a_cursor_and_enter_opens() {
578        let mut h = Harness::new(sidebar(false), 24, 7);
579        h.press("tab").press("down");
580        assert_eq!(h.app().page, "overview", "moving does not navigate");
581        assert!(h.screen().contains("▌  Deploys            3"), "{}", h.screen());
582        h.press("down").press("enter");
583        assert_eq!(h.app().page, "containers");
584        h.press("v");
585        h.press("space");
586        assert_eq!(h.app().page, "volumes");
587        h.press("home").press("enter");
588        assert_eq!(h.app().page, "overview");
589        h.click_text("Deploys");
590        assert_eq!(h.app().page, "deploys");
591    }
592
593    #[test]
594    fn the_scrollbar_is_dragged_and_never_opens_the_row_beside_it() {
595        let mut h = Harness::new(sidebar(false), 24, 4);
596        assert!(h.screen().starts_with("  WORKSPACE"), "{}", h.screen());
597        h.mouse(MouseKind::Down(MouseButton::Left), 23, 2);
598        h.mouse(MouseKind::Drag(MouseButton::Left), 23, 3);
599        h.mouse(MouseKind::Up(MouseButton::Left), 23, 3);
600        assert_eq!(h.app().page, "overview", "a press on the scrollbar opens nothing");
601        let screen = h.screen();
602        assert!(screen.contains("Volumes") && !screen.contains("WORKSPACE"), "dragged to the end:\n{screen}");
603        h.mouse(MouseKind::ScrollUp, 5, 1);
604        assert!(h.screen().starts_with("  WORKSPACE"), "{}", h.screen());
605    }
606
607    #[test]
608    fn the_pointer_carries_the_one_highlight() {
609        let mut h = Harness::new(sidebar(false), 24, 7);
610        h.press("tab").press("down");
611        assert!(h.screen().contains("▌  Deploys"), "{}", h.screen());
612        let (x, y) = h.find("Volumes").expect("volumes row");
613        h.hover(x + 2, y);
614        let screen = h.screen();
615        assert_eq!(
616            screen,
617            "  WORKSPACE\n▌  Overview\n  Deploys             3\n\n  INFRASTRUCTURE\n  ● Containers\n▌  Volumes\n",
618            "the keyboard's row gives way to the pointer's"
619        );
620        h.press("up");
621        let screen = h.screen();
622        assert!(screen.contains("▌  ● Containers") && screen.contains("\n  Volumes"), "{screen}");
623        h.press("enter");
624        assert_eq!(h.app().page, "containers", "the keyboard continued from the pointer's row");
625    }
626
627    #[test]
628    fn a_foldable_heading_rises_and_slides_while_its_chevron_stays() {
629        let mut h = Harness::new(sidebar(true), 24, 7);
630        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
631        h.hover(6, 0);
632        assert_eq!(h.screen().lines().next(), Some("▌  WORKSPACE          ▾"), "{}", h.screen());
633        assert_eq!(h.bg(10, 0), h.env().theme().color("raised"));
634        let mut env = crate::env::Env::builtin();
635        env.set_slide(false);
636        let mut h = Harness::with_env(sidebar(true), env, 24, 7);
637        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
638        h.hover(6, 0);
639        assert_eq!(h.screen().lines().next(), Some("▌ WORKSPACE           ▾"), "{}", h.screen());
640        let mut plain = Harness::new(sidebar(false), 24, 7);
641        plain.hover(6, 0);
642        assert_eq!(plain.screen().lines().next(), Some("  WORKSPACE"), "a heading that cannot fold never rises");
643    }
644
645    #[test]
646    fn collapsible_groups_fold_by_click_and_arrows() {
647        let mut h = Harness::new(sidebar(true), 24, 7);
648        assert!(h.screen().starts_with("  WORKSPACE           ▾\n"), "{}", h.screen());
649        h.click_text("INFRASTRUCTURE");
650        assert_eq!(h.app().closed, vec!["infra".to_owned()]);
651        assert!(!h.screen().contains("Volumes") && h.screen().contains('▶'), "{}", h.screen());
652        h.press("home").press("down").press("left");
653        assert_eq!(h.app().closed, vec!["infra".to_owned()], "left on an item goes to its heading");
654        h.press("left");
655        assert!(h.app().closed.contains(&"work".to_owned()), "{:?}", h.app().closed);
656        h.press("right");
657        assert!(!h.app().closed.contains(&"work".to_owned()));
658        let plain = Harness::new(sidebar(false), 24, 7);
659        assert!(!plain.screen().contains('▾'), "plain menus have no chevrons");
660    }
661}