Skip to main content

retroglyph_ui/widget/
menu.rs

1//! [`Menu`]: an anchored, scrollable, keyboard-navigable popup, the shared core a dropdown, a
2//! combobox's list, and a command palette all draw from.
3use retroglyph_core::color::{Color, Style};
4use retroglyph_core::grid::{Pos, Rect};
5
6use super::StatefulWidget;
7use crate::Surface;
8use crate::align::Align;
9use crate::draw::fill_rect;
10use crate::interact::Sense;
11use crate::state::MenuState;
12use crate::text::draw_clipped;
13use crate::theme::Theme;
14use crate::ui::Ui;
15
16/// A leading marker glyph for a [`MenuRow::Item`], drawn before its label.
17///
18/// Purely a rendering choice: every [`MenuRow::Item`] activates and closes the menu the same
19/// way regardless of its marker (dreadmire's "checkbox items fire and close" is true because
20/// there is no special-cased checkbox *behavior* here, only a checkbox *glyph*). Grouping
21/// [`Choice`](Self::Choice) rows into a mutually-exclusive set (so selecting one clears the
22/// others) is the caller's job, the same way this crate leaves "which tab is active" bookkeeping
23/// to whatever drives [`Tabs`](super::Tabs): set `Choice(true)` on exactly one row per group and
24/// update it on activation.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Marker {
27    /// No marker: the label starts at the row's left edge.
28    None,
29    /// A checkbox marker (`[x]`/`[ ]`), independently toggleable.
30    Checkbox(bool),
31    /// A radio/choice marker (`(o)`/`( )`), typically one-of-many within a caller-managed group.
32    Choice(bool),
33}
34
35impl Marker {
36    /// The literal text drawn before the label, including its trailing space.
37    const fn text(self) -> &'static str {
38        match self {
39            Self::None => "",
40            Self::Checkbox(true) => "[x] ",
41            Self::Checkbox(false) => "[ ] ",
42            Self::Choice(true) => "(o) ",
43            Self::Choice(false) => "( ) ",
44        }
45    }
46}
47
48/// One row of a [`Menu`].
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum MenuRow<'a> {
51    /// A selectable, activatable row.
52    Item {
53        /// The row's label text.
54        label: &'a str,
55        /// A leading checkbox/choice glyph, or [`Marker::None`] for a plain item.
56        marker: Marker,
57        /// Whether this row can be selected/activated. A disabled item is drawn with
58        /// [`Menu::disabled_style`] and skipped by keyboard navigation and clicks alike, the
59        /// same as a [`Separator`](Self::Separator)/[`Label`](Self::Label).
60        enabled: bool,
61    },
62    /// A non-selectable divider line.
63    Separator,
64    /// A non-selectable heading. Drawn like an [`Item`](Self::Item) with no marker, but never
65    /// selected by navigation or a click, the same as a disabled item.
66    Label(&'a str),
67}
68
69impl MenuRow<'_> {
70    /// Whether this row can be selected/activated: `true` only for an enabled
71    /// [`Item`](Self::Item).
72    #[must_use]
73    pub const fn is_selectable(&self) -> bool {
74        matches!(self, Self::Item { enabled: true, .. })
75    }
76}
77
78/// How many of a menu's rows a given `capacity` can show, and whether an overflow
79/// indicator is needed at either end.
80struct Layout {
81    show_top: bool,
82    show_bottom: bool,
83    content_capacity: usize,
84}
85
86/// An anchored, scrollable, keyboard-navigable popup: the shared core of a dropdown, a
87/// combobox's open list, and a command palette.
88///
89/// Draws whatever `capacity = surface.height()` of this menu's own rows the current
90/// [`MenuState::offset`] window covers, top-aligned. When more rows exist above/below that
91/// window, the first/last drawn row becomes a `^`/`v` overflow indicator instead of silently
92/// truncating -- call [`MenuState::ensure_visible`] before rendering (as [`Menu::show`] already
93/// does) to keep the selection inside the window.
94///
95/// This is the "anchored-popup core" alone: it draws rows and resolves a click/scroll/keyboard
96/// gesture against them, but has no opinion on *where* the popup sits relative to whatever
97/// anchored it (a menu-bar label, a combobox field, ...) -- that placement is the caller's job,
98/// same as [`Modal`](super::Modal) leaves centering to [`centered_rect`](crate::layout::centered_rect)
99/// rather than owning it itself.
100///
101/// [`Menu::show`] is the documented way to drive one open menu end-to-end each frame (drawing,
102/// hover/click/scroll, and click-outside dismissal); [`StatefulWidget::render`] is the lower-level
103/// draw-only half for a caller composing its own interaction (e.g. a menu bar switching which of
104/// several menus is open).
105///
106/// `item_style`/`selected_style`/`disabled_style`/`separator_style`/`label_style`/
107/// `overflow_style` default to [`Theme::DARK`] (as if [`Menu::theme`] had been called); set a
108/// different [`Theme`] with [`Menu::theme`]/[`Menu::theme_on`] or override a single field with
109/// the matching builder method.
110///
111/// # Examples
112///
113/// ```
114/// use retroglyph_core::grid::{Grid, Rect};
115/// use retroglyph_ui::state::MenuState;
116/// use retroglyph_ui::widget::{Marker, Menu, MenuRow, StatefulWidget};
117/// use retroglyph_ui::Surface;
118///
119/// let rows = [
120///     MenuRow::Item { label: "Cut", marker: Marker::None, enabled: false },
121///     MenuRow::Separator,
122///     MenuRow::Item { label: "Copy", marker: Marker::None, enabled: true },
123///     MenuRow::Item { label: "Word Wrap", marker: Marker::Checkbox(true), enabled: true },
124/// ];
125/// let mut state = MenuState::new();
126/// state.open(rows.len(), |i| rows[i].is_selectable());
127///
128/// let area = Rect::new(0, 0, 16, 4);
129/// let mut grid = Grid::new(16, 4);
130/// Menu::new(&rows).render(&mut Surface::new(&mut grid, area, 0), &mut state);
131/// ```
132#[derive(Clone, Copy, Debug)]
133pub struct Menu<'a> {
134    rows: &'a [MenuRow<'a>],
135    item_style: Style,
136    selected_style: Style,
137    disabled_style: Style,
138    separator_style: Style,
139    label_style: Style,
140    overflow_style: Style,
141}
142
143impl<'a> Menu<'a> {
144    /// A menu over `rows`, styled from [`Theme::DARK`] (as if [`Menu::theme`] had been called).
145    #[must_use]
146    pub fn new(rows: &'a [MenuRow<'a>]) -> Self {
147        Self {
148            rows,
149            item_style: Style::new(),
150            selected_style: Style::new(),
151            disabled_style: Style::new(),
152            separator_style: Style::new(),
153            label_style: Style::new(),
154            overflow_style: Style::new(),
155        }
156        .theme(Theme::DARK)
157    }
158
159    /// Set the style of a plain, enabled, unselected [`MenuRow::Item`].
160    #[must_use]
161    pub const fn item_style(mut self, style: Style) -> Self {
162        self.item_style = style;
163        self
164    }
165
166    /// Set the style of the selected row, including its background fill.
167    #[must_use]
168    pub const fn selected_style(mut self, style: Style) -> Self {
169        self.selected_style = style;
170        self
171    }
172
173    /// Set the style of a disabled [`MenuRow::Item`].
174    #[must_use]
175    pub const fn disabled_style(mut self, style: Style) -> Self {
176        self.disabled_style = style;
177        self
178    }
179
180    /// Set the style of a [`MenuRow::Separator`] line.
181    #[must_use]
182    pub const fn separator_style(mut self, style: Style) -> Self {
183        self.separator_style = style;
184        self
185    }
186
187    /// Set the style of a [`MenuRow::Label`] row.
188    #[must_use]
189    pub const fn label_style(mut self, style: Style) -> Self {
190        self.label_style = style;
191        self
192    }
193
194    /// Set the style of the `^`/`v` overflow indicator rows.
195    #[must_use]
196    pub const fn overflow_style(mut self, style: Style) -> Self {
197        self.overflow_style = style;
198        self
199    }
200
201    /// Applies `theme`'s named roles: `item_style`/`label_style` become `theme.fg` on
202    /// `theme.panel_bg`, `selected_style` becomes `theme.bg` on `theme.accent`, `disabled_style`
203    /// becomes `theme.dim` on `theme.panel_bg`, and `separator_style`/`overflow_style` become
204    /// `theme.border` on `theme.panel_bg`.
205    ///
206    /// Call before any manual per-field override you want to keep.
207    #[must_use]
208    pub fn theme(self, theme: Theme) -> Self {
209        self.theme_on(theme, theme.panel_bg)
210    }
211
212    /// Same as [`Menu::theme`], but every style is drawn on `bg` instead of `theme.panel_bg`.
213    #[must_use]
214    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
215        self.item_style = Style::new().fg(theme.fg).bg(bg);
216        self.label_style = Style::new().fg(theme.fg).bg(bg);
217        self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
218        self.disabled_style = Style::new().fg(theme.dim).bg(bg);
219        self.separator_style = Style::new().fg(theme.border).bg(bg);
220        self.overflow_style = Style::new().fg(theme.border).bg(bg);
221        self
222    }
223}
224
225impl Menu<'_> {
226    /// How many of a menu's rows a `capacity`-row surface can show from `state.offset()`
227    /// onward, and whether either end needs an overflow indicator instead of a real row.
228    fn layout(&self, state: &MenuState, capacity: usize) -> Layout {
229        if capacity == 0 {
230            return Layout {
231                show_top: false,
232                show_bottom: false,
233                content_capacity: 0,
234            };
235        }
236        let show_top = state.offset() > 0;
237        let mut content_capacity = capacity - usize::from(show_top);
238        let show_bottom = self.rows.len().saturating_sub(state.offset()) > content_capacity;
239        if show_bottom {
240            content_capacity = content_capacity.saturating_sub(1);
241        }
242        Layout {
243            show_top,
244            show_bottom,
245            content_capacity,
246        }
247    }
248
249    /// The shared drawing routine.
250    fn draw(&self, surface: &mut Surface<'_>, state: &MenuState) {
251        let (width, height) = (surface.width(), surface.height());
252        if width == 0 || height == 0 || self.rows.is_empty() {
253            return;
254        }
255        let layout = self.layout(state, usize::from(height));
256
257        let mut y: u16 = 0;
258        if layout.show_top {
259            let _ = draw_clipped(
260                surface,
261                (0, y),
262                width,
263                "^",
264                Align::Left,
265                self.overflow_style,
266            );
267            y += 1;
268        }
269        for (index, row) in self
270            .rows
271            .iter()
272            .enumerate()
273            .skip(state.offset())
274            .take(layout.content_capacity)
275        {
276            let is_selected = Some(index) == state.selected();
277            let style = match row {
278                MenuRow::Separator => self.separator_style,
279                MenuRow::Label(_) => self.label_style,
280                MenuRow::Item { enabled: false, .. } => self.disabled_style,
281                MenuRow::Item { .. } if is_selected => self.selected_style,
282                MenuRow::Item { .. } => self.item_style,
283            };
284            if is_selected {
285                fill_rect(
286                    surface,
287                    Rect::new(0, y, width, 1),
288                    ' ',
289                    Style::new().bg(self.selected_style.background()),
290                );
291            }
292            match row {
293                MenuRow::Separator => {
294                    fill_rect(surface, Rect::new(0, y, width, 1), '\u{2500}', style);
295                }
296                MenuRow::Label(text) => {
297                    let _ = draw_clipped(surface, (0, y), width, text, Align::Left, style);
298                }
299                MenuRow::Item { label, marker, .. } => {
300                    let marker_text = marker.text();
301                    let marker_width = retroglyph_core::text::width(marker_text).min(width);
302                    if marker_width > 0 {
303                        let _ = draw_clipped(
304                            surface,
305                            (0, y),
306                            marker_width,
307                            marker_text,
308                            Align::Left,
309                            style,
310                        );
311                    }
312                    let _ = draw_clipped(
313                        surface,
314                        (marker_width, y),
315                        width - marker_width,
316                        label,
317                        Align::Left,
318                        style,
319                    );
320                }
321            }
322            y += 1;
323        }
324        if layout.show_bottom {
325            let _ = draw_clipped(
326                surface,
327                (0, y),
328                width,
329                "v",
330                Align::Left,
331                self.overflow_style,
332            );
333        }
334    }
335
336    /// The row index at `pos`, given `state`'s current scroll offset, or `None` if `pos` falls
337    /// on an overflow indicator, past the last drawn row, or outside `area`.
338    fn index_at(&self, area: Rect, state: &MenuState, pos: Pos) -> Option<usize> {
339        let capacity = usize::from(area.height());
340        let layout = self.layout(state, capacity);
341        let row = usize::from(pos.y.checked_sub(area.top())?);
342        let content_row = row.checked_sub(usize::from(layout.show_top))?;
343        if content_row >= layout.content_capacity {
344            return None; // the top/bottom overflow indicator, or past the window entirely
345        }
346        let index = state.offset() + content_row;
347        (index < self.rows.len()).then_some(index)
348    }
349}
350
351impl StatefulWidget for Menu<'_> {
352    type State = MenuState;
353
354    fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
355        self.draw(surface, state);
356    }
357}
358
359impl Menu<'_> {
360    /// Draw and interact with this menu for one frame: the documented way to drive an anchored
361    /// popup end-to-end, exercising the same primitives a menu bar/combobox/command palette all
362    /// need.
363    ///
364    /// A no-op (returns `None`, draws nothing) while `state.is_open()` is `false`. Otherwise:
365    ///
366    /// - Registers `screen` for `backdrop_id` *before* opening a [`Ui::modal`] scoped to
367    ///   `popup_area`: because a modal's barrier only blocks hit-testing *inside* `popup_area`
368    ///   (see [`Ui::modal`]'s docs), a click landing anywhere in `screen` but outside
369    ///   `popup_area` still reaches the backdrop and is treated as "outside every hitbox",
370    ///   closing the menu without activating anything. A click inside `popup_area` is claimed by
371    ///   the popup's own rows instead, since those are registered *after* the barrier and always
372    ///   win inside it.
373    /// - Draws the window of rows `state.offset()` selects (calling
374    ///   [`MenuState::ensure_visible`] first so the selection is never scrolled out of view), and
375    ///   applies a hover/click/wheel-scroll gesture the same way [`Menu::render`] alone would if
376    ///   driven through [`Ui::show_stateful`].
377    /// - Returns `Some(row_index)` for the one frame a selectable row is clicked, closing the
378    ///   menu as a side effect ("checkbox items fire and close"). Returns `None` on every other
379    ///   frame, including the one where an outside click closes the menu.
380    ///
381    /// `content_id` and `backdrop_id` must be distinct: sharing one `Id` between them would make
382    /// both resolve the same hit-test winner, breaking the inside/outside distinction above.
383    ///
384    /// Keyboard navigation/activation (Up/Down/Enter/Escape) is not handled here: feed raw
385    /// events to [`MenuState::handle_event`] instead, in your event-handling loop, the same split
386    /// [`TextInputState::handle_event`](crate::state::TextInputState::handle_event) has from drawing.
387    #[must_use]
388    pub fn show<Id: Copy + PartialEq>(
389        &self,
390        ui: &mut Ui<'_, '_, Id>,
391        screen: Rect,
392        popup_area: Rect,
393        content_id: Id,
394        backdrop_id: Id,
395        state: &mut MenuState,
396    ) -> Option<usize> {
397        if !state.is_open() {
398            return None;
399        }
400
401        let backdrop = ui.region(screen, backdrop_id, Sense::click()).0;
402
403        let mut activated = None;
404        ui.modal(popup_area, |inner| {
405            let (response, mut surface) = inner.region(
406                popup_area,
407                content_id,
408                Sense::click() | Sense::HOVER | Sense::SCROLL,
409            );
410
411            let scroll_delta = response.scroll_delta();
412            if scroll_delta != 0 {
413                state.scroll_by(scroll_delta);
414            }
415            if response.clicked()
416                && let Some(pos) = response.pointer_pos()
417                && let Some(index) = self.index_at(popup_area, state, pos)
418                && self.rows[index].is_selectable()
419            {
420                state.select(Some(index));
421                activated = Some(index);
422            }
423
424            state.ensure_visible(usize::from(popup_area.height()));
425            self.draw(&mut surface, state);
426        });
427
428        if activated.is_some() || backdrop.clicked() {
429            state.close();
430        }
431
432        activated
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use retroglyph_core::grid::{Grid, Pos};
439
440    use super::*;
441    use crate::interact::Interaction;
442
443    const ROWS: [MenuRow<'static>; 5] = [
444        MenuRow::Separator,
445        MenuRow::Item {
446            label: "Cut",
447            marker: Marker::None,
448            enabled: false,
449        },
450        MenuRow::Item {
451            label: "Copy",
452            marker: Marker::None,
453            enabled: true,
454        },
455        MenuRow::Item {
456            label: "Word Wrap",
457            marker: Marker::Checkbox(true),
458            enabled: true,
459        },
460        MenuRow::Label("More"),
461    ];
462
463    fn selectable(i: usize) -> bool {
464        ROWS[i].is_selectable()
465    }
466
467    #[test]
468    fn draws_a_row_per_line() {
469        let area = Rect::new(0, 0, 20, 5);
470        let mut grid = Grid::new(20, 5);
471        let mut state = MenuState::new();
472        state.open(ROWS.len(), selectable);
473
474        StatefulWidget::render(
475            &Menu::new(&ROWS),
476            &mut Surface::new(&mut grid, area, 0),
477            &mut state,
478        );
479
480        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C'); // "Cut"
481        assert_eq!(grid[Pos::new(0, 2)].glyph(), 'C'); // "Copy"
482        assert_eq!(grid[Pos::new(0, 3)].glyph(), '['); // checkbox marker
483    }
484
485    #[test]
486    fn selected_row_is_highlighted() {
487        let area = Rect::new(0, 0, 20, 5);
488        let mut grid = Grid::new(20, 5);
489        let mut state = MenuState::new();
490        state.open(ROWS.len(), selectable); // selects "Copy" (index 2)
491
492        StatefulWidget::render(
493            &Menu::new(&ROWS),
494            &mut Surface::new(&mut grid, area, 0),
495            &mut state,
496        );
497
498        let selected_bg = grid[Pos::new(0, 2)].style().background();
499        let plain_bg = grid[Pos::new(0, 3)].style().background();
500        assert_ne!(selected_bg, plain_bg);
501    }
502
503    #[test]
504    fn overflow_draws_indicators_instead_of_silent_truncation() {
505        let area = Rect::new(0, 0, 20, 3); // only 3 of 5 rows fit
506        let mut grid = Grid::new(20, 3);
507        let mut state = MenuState::new();
508        state.open(ROWS.len(), selectable);
509        state.set_offset(1); // window starts at "Cut", so there's overflow both ways
510
511        StatefulWidget::render(
512            &Menu::new(&ROWS),
513            &mut Surface::new(&mut grid, area, 0),
514            &mut state,
515        );
516
517        assert_eq!(grid[Pos::new(0, 0)].glyph(), '^');
518        assert_eq!(grid[Pos::new(0, 2)].glyph(), 'v');
519    }
520
521    #[test]
522    fn index_at_skips_the_overflow_indicator_rows() {
523        let area = Rect::new(0, 0, 20, 3);
524        let mut state = MenuState::new();
525        state.open(ROWS.len(), selectable);
526        state.set_offset(1);
527
528        let menu = Menu::new(&ROWS);
529        assert_eq!(menu.index_at(area, &state, Pos::new(0, 0)), None); // '^' row
530        assert_eq!(menu.index_at(area, &state, Pos::new(0, 1)), Some(1)); // "Cut"
531        assert_eq!(menu.index_at(area, &state, Pos::new(0, 2)), None); // 'v' row
532    }
533
534    #[derive(Clone, Copy, PartialEq, Eq)]
535    enum Id {
536        Content,
537        Backdrop,
538    }
539
540    #[test]
541    fn show_returns_none_and_draws_nothing_while_closed() {
542        let screen = Rect::new(0, 0, 20, 10);
543        let popup = Rect::new(2, 2, 10, 5);
544        let mut grid = Grid::new(20, 10);
545        let mut state = MenuState::new();
546        let mut interaction = Interaction::<Id>::new();
547
548        let activated = interaction.frame(&mut Surface::new(&mut grid, screen, 0), |ui| {
549            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
550        });
551
552        assert_eq!(activated, None);
553        assert_eq!(grid[Pos::new(2, 2)].glyph(), ' ');
554    }
555
556    // The three multi-frame tests below drive `Menu::show` through `WidgetHarness` instead of
557    // hand-rolling `Interaction`'s register/feed/resolve frame pair: gated behind `testing` (see
558    // `crate::testing::WidgetHarness`), since `menu.rs` itself isn't feature-gated but the
559    // harness is.
560    #[test]
561    #[cfg(feature = "testing")]
562    fn clicking_a_selectable_row_activates_and_closes() {
563        use crate::testing::WidgetHarness;
564
565        let screen = Rect::new(0, 0, 20, 10);
566        let popup = Rect::new(0, 0, 20, 5); // "Copy" (index 2) draws at row y=2
567        let mut state = MenuState::new();
568        state.open(ROWS.len(), selectable);
569        let mut harness = WidgetHarness::<Id>::new(20, 10);
570
571        // Frame 1: register this frame's hit rects (nothing fed in yet).
572        harness.step(|ui| {
573            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
574        });
575
576        harness.click(1, 2); // "Copy"'s row
577        // Frame 2: feeds the click; the flags it sets aren't read until the next `step`.
578        let _ = harness.step(|ui| {
579            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
580        });
581        // Frame 3: resolves it.
582        let activated = harness.step(|ui| {
583            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
584        });
585
586        assert_eq!(activated, Some(2));
587        assert!(!state.is_open());
588    }
589
590    #[test]
591    #[cfg(feature = "testing")]
592    fn clicking_outside_the_popup_closes_without_activating() {
593        use crate::testing::WidgetHarness;
594
595        let screen = Rect::new(0, 0, 20, 10);
596        let popup = Rect::new(0, 0, 10, 5);
597        let mut state = MenuState::new();
598        state.open(ROWS.len(), selectable);
599        let mut harness = WidgetHarness::<Id>::new(20, 10);
600
601        harness.step(|ui| {
602            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
603        });
604
605        // Well outside `popup` (which only spans columns 0..10, rows 0..5).
606        harness.click(15, 8);
607        let _ = harness.step(|ui| {
608            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
609        });
610        let activated = harness.step(|ui| {
611            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
612        });
613
614        assert_eq!(activated, None);
615        assert!(!state.is_open());
616    }
617
618    #[test]
619    #[cfg(feature = "testing")]
620    fn clicking_inside_the_popup_but_off_every_row_stays_open() {
621        // retroglyph#1290: a click that lands inside `popup_area` but past the drawn rows (e.g.
622        // in the empty space below a short menu) must not fall through to the backdrop: the
623        // barrier blocks it from ever reaching `screen`'s registration.
624        use crate::testing::WidgetHarness;
625
626        let screen = Rect::new(0, 0, 20, 10);
627        let popup = Rect::new(0, 0, 20, 8); // taller than the 5 rows it actually draws
628        let mut state = MenuState::new();
629        state.open(ROWS.len(), selectable);
630        let mut harness = WidgetHarness::<Id>::new(20, 10);
631
632        harness.step(|ui| {
633            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
634        });
635
636        harness.click(1, 7); // inside `popup`, past the last drawn row
637        let _ = harness.step(|ui| {
638            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
639        });
640        let activated = harness.step(|ui| {
641            Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
642        });
643
644        assert_eq!(activated, None);
645        assert!(state.is_open()); // still open: the click never reached the backdrop
646    }
647}