Skip to main content

densa_widgets/
menu.rs

1//! Menus: a [`Menu`] pops up a list of commands; a [`MenuBar`] holds the titles of a
2//! window's menus. Choosing a command reports [`ActionKind::Command`] with the number the
3//! application gave it.
4
5use densa_core::{Color, Point, Rect, Size};
6use densa_draw::Canvas;
7use densa_ui::{
8    AccessAction, AccessNode, AccessRequest, ActionKind, Anchor, Constraints, Cursor, Event,
9    EventCx, Handled, Key, LayoutCx, NamedKey, PaintCx, PointerButton, PointerKind, Role, Style,
10    Widget, WidgetId,
11};
12
13use crate::paint::outline;
14use crate::text::Text;
15
16/// Space around a menu's rows, in logical pixels.
17const MENU_PADDING: f32 = 4.0;
18/// Space above and below a row's text.
19const ROW_PADDING_Y: f32 = 4.0;
20/// The column for check marks, left of the labels.
21const GUTTER: f32 = 22.0;
22/// Space between a label and its shortcut.
23const SHORTCUT_GAP: f32 = 28.0;
24/// The column for submenu arrows, right of the shortcuts.
25const ARROW: f32 = 18.0;
26/// A separator's height.
27const SEPARATOR: f32 = 9.0;
28/// Space either side of a menu bar title.
29const TITLE_PADDING_X: f32 = 10.0;
30const TITLE_PADDING_Y: f32 = 5.0;
31
32/// An entry of a [`Menu`]: a command, a submenu or a separator.
33#[derive(Debug, Clone, PartialEq)]
34pub struct MenuItem {
35    kind: ItemKind,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39enum ItemKind {
40    Command {
41        label: String,
42        command: u32,
43        shortcut: Option<String>,
44        enabled: bool,
45        checked: Option<bool>,
46    },
47    Submenu {
48        label: String,
49        items: Vec<MenuItem>,
50        enabled: bool,
51    },
52    Separator,
53}
54
55impl MenuItem {
56    /// A command labeled `label`: choosing it reports [`ActionKind::Command`]`(command)`.
57    pub fn new(label: impl Into<String>, command: u32) -> Self {
58        Self {
59            kind: ItemKind::Command {
60                label: label.into(),
61                command,
62                shortcut: None,
63                enabled: true,
64                checked: None,
65            },
66        }
67    }
68
69    /// An entry that opens `items` beside it.
70    pub fn submenu(label: impl Into<String>, items: impl IntoIterator<Item = MenuItem>) -> Self {
71        Self {
72            kind: ItemKind::Submenu {
73                label: label.into(),
74                items: items.into_iter().collect(),
75                enabled: true,
76            },
77        }
78    }
79
80    /// A line between groups of entries.
81    pub fn separator() -> Self {
82        Self {
83            kind: ItemKind::Separator,
84        }
85    }
86
87    /// Shows `shortcut` (such as "Ctrl+C") at the right of a command. It is only shown: the
88    /// application handles the keys.
89    #[must_use]
90    pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
91        if let ItemKind::Command { shortcut: s, .. } = &mut self.kind {
92            *s = Some(shortcut.into());
93        }
94        self
95    }
96
97    /// The entry available (the default) or not: an unavailable one is shown dimmed and
98    /// cannot be chosen.
99    #[must_use]
100    pub fn enabled(mut self, enabled: bool) -> Self {
101        match &mut self.kind {
102            ItemKind::Command { enabled: e, .. } | ItemKind::Submenu { enabled: e, .. } => {
103                *e = enabled;
104            }
105            ItemKind::Separator => {}
106        }
107        self
108    }
109
110    /// Makes a command one that is on (checked) or off, shown with a check mark when on.
111    /// Choosing it reports the command; the application turns it on or off.
112    #[must_use]
113    pub fn checked(mut self, checked: bool) -> Self {
114        if let ItemKind::Command { checked: c, .. } = &mut self.kind {
115            *c = Some(checked);
116        }
117        self
118    }
119
120    fn label(&self) -> &str {
121        match &self.kind {
122            ItemKind::Command { label, .. } | ItemKind::Submenu { label, .. } => label,
123            ItemKind::Separator => "",
124        }
125    }
126
127    fn is_enabled(&self) -> bool {
128        match &self.kind {
129            ItemKind::Command { enabled, .. } | ItemKind::Submenu { enabled, .. } => *enabled,
130            ItemKind::Separator => false,
131        }
132    }
133}
134
135/// A row of a menu, as laid out.
136#[derive(Debug)]
137struct Row {
138    item: MenuItem,
139    label: Text,
140    shortcut: Option<Text>,
141    /// Top and bottom, from the menu's top edge, in physical pixels.
142    top: f32,
143    bottom: f32,
144}
145
146/// A list of commands that pops up: open it with [`Ui::open_popup`] (a context menu) or
147/// from a [`MenuBar`].
148///
149/// The pointer highlights entries and a release on a command chooses it; a submenu opens
150/// beside its entry when the pointer comes over it. Up and Down move the highlight, Enter or
151/// Space chooses, Right opens a submenu and Left or Escape closes one; a key the menu does
152/// not use goes on to what opened it (Left and Right switch a menu bar's menus). Choosing a
153/// command reports [`ActionKind::Command`] and closes every menu.
154///
155/// [`Ui::open_popup`]: densa_ui::Ui::open_popup
156#[derive(Debug)]
157pub struct Menu {
158    rows: Vec<Row>,
159    check: Text,
160    arrow: Text,
161    highlighted: Option<usize>,
162    /// The submenu open, and the row it belongs to.
163    open: Option<(usize, WidgetId)>,
164    /// Highlight the first entry when shown (opened from the keyboard).
165    from_keyboard: bool,
166    /// Opened by another menu: Left closes it.
167    nested: bool,
168    /// The pointer has come over it (a submenu opened by hovering takes the keyboard then).
169    entered: bool,
170    /// Where the pointer last was over it: moving toward an open submenu keeps it open.
171    last_pointer: Option<Point>,
172}
173
174impl Menu {
175    /// A menu of `items`.
176    pub fn new(items: impl IntoIterator<Item = MenuItem>) -> Self {
177        let rows = items
178            .into_iter()
179            .map(|item| Row {
180                label: Text::new(item.label()),
181                shortcut: match &item.kind {
182                    ItemKind::Command {
183                        shortcut: Some(s), ..
184                    } => Some(Text::new(s.as_str())),
185                    _ => None,
186                },
187                item,
188                top: 0.0,
189                bottom: 0.0,
190            })
191            .collect();
192        Self {
193            rows,
194            check: Text::new("\u{2713}"),
195            arrow: Text::new("\u{203A}"),
196            highlighted: None,
197            open: None,
198            from_keyboard: false,
199            nested: false,
200            entered: false,
201            last_pointer: None,
202        }
203    }
204
205    /// The style to open a menu with: it sizes itself.
206    pub fn style() -> Style {
207        Style::column()
208    }
209
210    /// The menu with its first available entry highlighted, as when opened from the keyboard.
211    #[must_use]
212    pub fn highlighting_first(mut self) -> Self {
213        self.from_keyboard = true;
214        self.highlighted = self.next_enabled(None, true);
215        self
216    }
217
218    /// The entry highlighted, by index.
219    pub fn highlighted(&self) -> Option<usize> {
220        self.highlighted
221    }
222
223    /// The next available entry after (`forward`) or before `from`, wrapping around; from
224    /// the first (or last) when `from` is `None`.
225    fn next_enabled(&self, from: Option<usize>, forward: bool) -> Option<usize> {
226        let count = self.rows.len();
227        if count == 0 {
228            return None;
229        }
230        let start = match (from, forward) {
231            (Some(at), true) => at + 1,
232            (Some(at), false) => at + count - 1,
233            (None, true) => 0,
234            (None, false) => count - 1,
235        };
236        (0..count)
237            .map(|step| {
238                if forward {
239                    (start + step) % count
240                } else {
241                    (start + count - step % count) % count
242                }
243            })
244            .find(|&index| self.rows[index].item.is_enabled())
245    }
246
247    fn row_at(&self, rect: Rect, at: Point) -> Option<usize> {
248        if !rect.contains(at) {
249            return None;
250        }
251        let y = at.y - rect.top();
252        self.rows
253            .iter()
254            .position(|row| y >= row.top && y < row.bottom)
255    }
256
257    fn row_rect(&self, rect: Rect, index: usize) -> Rect {
258        let row = &self.rows[index];
259        Rect::from_edges(
260            rect.left(),
261            rect.top() + row.top,
262            rect.right(),
263            rect.top() + row.bottom,
264        )
265    }
266
267    /// Highlights row `index` (`None`: nothing), closing a submenu of another row and opening
268    /// its own if it has one and `open_submenu` holds.
269    fn highlight(&mut self, cx: &mut EventCx<'_>, index: Option<usize>, open_submenu: bool) {
270        if self.highlighted != index {
271            self.highlighted = index;
272            cx.request_paint();
273        }
274        if let Some((row, id)) = self.open {
275            if Some(row) != index || !cx.is_popup_open(id) {
276                cx.close_popup(id);
277                self.open = None;
278            }
279        }
280        if open_submenu {
281            if let Some(index) = index {
282                self.open_submenu(cx, index, false);
283            }
284        }
285    }
286
287    /// Opens row `index`'s submenu, if it has one and it is not open.
288    fn open_submenu(&mut self, cx: &mut EventCx<'_>, index: usize, from_keyboard: bool) -> bool {
289        if self
290            .open
291            .is_some_and(|(row, id)| row == index && cx.is_popup_open(id))
292        {
293            return true;
294        }
295        let ItemKind::Submenu { items, enabled, .. } = &self.rows[index].item.kind else {
296            return false;
297        };
298        if !*enabled {
299            return false;
300        }
301        let mut menu = Menu::new(items.iter().cloned());
302        menu.nested = true;
303        if from_keyboard {
304            menu = menu.highlighting_first();
305        }
306        let anchor = Anchor::beside(self.row_rect(cx.rect(), index));
307        match cx.open_popup(menu, anchor, Menu::style()) {
308            Ok(id) => {
309                self.open = Some((index, id));
310                true
311            }
312            Err(_) => false,
313        }
314    }
315
316    /// Chooses row `index`: reports its command and closes every menu, or opens its submenu.
317    fn choose(&mut self, cx: &mut EventCx<'_>, index: usize, from_keyboard: bool) {
318        match &self.rows[index].item.kind {
319            ItemKind::Command {
320                command, enabled, ..
321            } if *enabled => {
322                cx.emit(ActionKind::Command(*command));
323                cx.close_popups();
324            }
325            ItemKind::Submenu { .. } => {
326                self.open_submenu(cx, index, from_keyboard);
327            }
328            _ => {}
329        }
330    }
331
332    fn key(&mut self, cx: &mut EventCx<'_>, key: NamedKey) -> Handled {
333        match key {
334            NamedKey::ArrowDown | NamedKey::ArrowUp => {
335                let next = self.next_enabled(self.highlighted, key == NamedKey::ArrowDown);
336                self.highlight(cx, next, false);
337                Handled::Yes
338            }
339            NamedKey::Home | NamedKey::End => {
340                let next = self.next_enabled(None, key == NamedKey::Home);
341                self.highlight(cx, next, false);
342                Handled::Yes
343            }
344            NamedKey::Enter | NamedKey::Space => {
345                if let Some(index) = self.highlighted {
346                    self.choose(cx, index, true);
347                }
348                Handled::Yes
349            }
350            NamedKey::ArrowRight => {
351                // A submenu passes Right on while one of its own is open: to the menu bar.
352                let open = self.open.is_some_and(|(_, id)| cx.is_popup_open(id));
353                match self.highlighted {
354                    Some(index) if !open => self.open_submenu(cx, index, true).into(),
355                    _ => Handled::No,
356                }
357            }
358            NamedKey::ArrowLeft if self.nested => {
359                cx.close_popup(cx.id());
360                Handled::Yes
361            }
362            NamedKey::Escape => {
363                cx.close_popup(cx.id());
364                Handled::Yes
365            }
366            // Left from a menu that is not a submenu goes on to its menu bar.
367            NamedKey::ArrowLeft => Handled::No,
368            // Nothing else gets past an open menu (Tab included).
369            _ => Handled::Yes,
370        }
371    }
372
373    /// Whether the pointer, now at `at`, is moving toward the open submenu while level with
374    /// it: crossing other rows on the way should not close it.
375    fn heading_to_submenu(&self, cx: &EventCx<'_>, at: Point) -> bool {
376        let (Some((_, id)), Some(last)) = (self.open, self.last_pointer) else {
377            return false;
378        };
379        let Some(submenu) = cx.rect_of(id).filter(|_| cx.is_popup_open(id)) else {
380            return false;
381        };
382        let toward = if submenu.left() >= cx.rect().right() - 1.0 {
383            at.x > last.x
384        } else {
385            at.x < last.x
386        };
387        toward && at.y >= submenu.top() && at.y < submenu.bottom()
388    }
389}
390
391/// Whether `label` starts with `typed`'s first character, ignoring case.
392fn starts_with_key(label: &str, typed: &str) -> bool {
393    match (label.chars().next(), typed.chars().next()) {
394        (Some(first), Some(key)) => first.to_lowercase().eq(key.to_lowercase()),
395        _ => false,
396    }
397}
398
399/// Shapes `text` in `color` on one line; its size.
400fn shaped(text: &mut Text, cx: &mut LayoutCx<'_>, color: Color) -> Size {
401    text.fit(cx, f32::INFINITY, color);
402    text.size()
403}
404
405impl Widget for Menu {
406    fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
407        let theme = cx.theme().clone();
408        let check = shaped(&mut self.check, cx, theme.text);
409        let arrow = shaped(&mut self.arrow, cx, theme.text_dim);
410        let (mut label_width, mut shortcut_width, mut submenus) = (0.0_f32, 0.0_f32, false);
411        let mut y = cx.px(MENU_PADDING);
412        let line = check.height.max(arrow.height);
413        for row in &mut self.rows {
414            let color = if row.item.is_enabled() {
415                theme.text
416            } else {
417                theme.text_dim
418            };
419            row.top = y;
420            if matches!(row.item.kind, ItemKind::Separator) {
421                y += cx.px(SEPARATOR).round();
422                row.bottom = y;
423                continue;
424            }
425            let label = shaped(&mut row.label, cx, color);
426            label_width = label_width.max(label.width);
427            if let Some(shortcut) = &mut row.shortcut {
428                shortcut_width = shortcut_width.max(shaped(shortcut, cx, theme.text_dim).width);
429            }
430            submenus |= matches!(row.item.kind, ItemKind::Submenu { .. });
431            y += (label.height.max(line) + 2.0 * cx.px(ROW_PADDING_Y)).round();
432            row.bottom = y;
433        }
434        let shortcuts = if shortcut_width > 0.0 {
435            cx.px(SHORTCUT_GAP) + shortcut_width
436        } else {
437            0.0
438        };
439        let arrows = if submenus { cx.px(ARROW) } else { 0.0 };
440        let width = 2.0 * cx.px(MENU_PADDING)
441            + cx.px(GUTTER)
442            + label_width
443            + shortcuts
444            + arrows
445            + cx.px(GUTTER) / 2.0;
446        Size::new(width.ceil(), (y + cx.px(MENU_PADDING)).ceil())
447    }
448
449    fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
450        let theme = cx.theme();
451        let rect = cx.rect();
452        // A soft edge below and to the right: the menu floats over what it covers.
453        for (offset, alpha) in [(1.0, 0.22), (2.0, 0.14), (3.0, 0.07)] {
454            let d = cx.px(offset).round();
455            let shadow = Rect::from_edges(
456                rect.left() + d,
457                rect.top() + d,
458                rect.right() + d,
459                rect.bottom() + d,
460            );
461            canvas.fill_rect(shadow, Color::BLACK.with_alpha(alpha));
462        }
463        canvas.fill_rect(rect, theme.raised);
464        outline(canvas, rect, cx.px(1.0).round(), theme.border);
465        let padding = cx.px(MENU_PADDING);
466        let (left, right) = (rect.left() + padding, rect.right() - padding);
467        for (index, row) in self.rows.iter().enumerate() {
468            let (top, bottom) = (rect.top() + row.top, rect.top() + row.bottom);
469            if matches!(row.item.kind, ItemKind::Separator) {
470                let middle = ((top + bottom) / 2.0).round();
471                let line = cx.px(1.0).round();
472                canvas.fill_rect(
473                    Rect::from_edges(left, middle, right, middle + line),
474                    theme.border,
475                );
476                continue;
477            }
478            let open = self
479                .open
480                .is_some_and(|(row, id)| row == index && cx.is_popup_open(id));
481            if self.highlighted == Some(index) || open {
482                canvas.fill_rect(Rect::from_edges(left, top, right, bottom), theme.hover);
483            }
484            let text_top = |height: f32| (top + (bottom - top - height) / 2.0).round();
485            if let ItemKind::Command {
486                checked: Some(true),
487                ..
488            } = row.item.kind
489            {
490                let size = self.check.size();
491                let x = (left + (cx.px(GUTTER) - size.width) / 2.0).round();
492                self.check
493                    .paint(canvas, Point::new(x, text_top(size.height)));
494            }
495            let label = row.label.size();
496            let x = (left + cx.px(GUTTER)).round();
497            row.label
498                .paint(canvas, Point::new(x, text_top(label.height)));
499            let mut end = right - cx.px(GUTTER) / 2.0;
500            if matches!(row.item.kind, ItemKind::Submenu { .. }) {
501                let size = self.arrow.size();
502                let at = Point::new((end - size.width).round(), text_top(size.height));
503                self.arrow.paint(canvas, at);
504                end -= cx.px(ARROW);
505            }
506            if let Some(shortcut) = &row.shortcut {
507                let size = shortcut.size();
508                let at = Point::new((end - size.width).round(), text_top(size.height));
509                shortcut.paint(canvas, at);
510            }
511        }
512    }
513
514    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
515        if let Some((_, id)) = self.open {
516            if !cx.is_popup_open(id) {
517                self.open = None;
518            }
519        }
520        match event {
521            Event::Pointer(pointer) => match pointer.kind {
522                PointerKind::Enter | PointerKind::Move => {
523                    cx.set_cursor(Some(Cursor::Default));
524                    if self.nested && !self.entered {
525                        // A submenu opened by hovering takes the keyboard once the pointer
526                        // comes over it.
527                        self.entered = true;
528                        cx.request_focus();
529                    }
530                    let at = pointer.position;
531                    let heading = self.heading_to_submenu(cx, at);
532                    self.last_pointer = Some(at);
533                    let row = self
534                        .row_at(cx.rect(), at)
535                        .filter(|&index| self.rows[index].item.is_enabled());
536                    // Between rows, over a separator, or on the way to it diagonally across
537                    // other rows: keep the submenu open.
538                    if !heading && (row.is_some() || self.open.is_none()) {
539                        self.highlight(cx, row, true);
540                    }
541                    Handled::Yes
542                }
543                PointerKind::Leave => {
544                    self.last_pointer = None;
545                    if self.open.is_none() {
546                        self.highlight(cx, None, false);
547                    }
548                    Handled::Yes
549                }
550                PointerKind::Up(PointerButton::Primary) => {
551                    if let Some(index) = self.row_at(cx.rect(), pointer.position) {
552                        self.choose(cx, index, false);
553                    }
554                    Handled::Yes
555                }
556                PointerKind::Down(_) | PointerKind::Up(_) | PointerKind::Scroll(_) => Handled::Yes,
557                _ => Handled::No,
558            },
559            Event::Key(key) if key.pressed => match key.key {
560                Key::Named(named) => self.key(cx, named),
561                // Shortcuts do not reach the window behind an open menu.
562                Key::Character(_) if key.modifiers.control() || key.modifiers.alt() => Handled::Yes,
563                Key::Character(typed) => {
564                    // Typing a letter jumps to the next entry starting with it.
565                    let count = self.rows.len();
566                    let start = self.highlighted.map_or(0, |at| at + 1);
567                    let found = (0..count)
568                        .map(|step| (start + step) % count)
569                        .find(|&index| {
570                            let row = &self.rows[index];
571                            row.item.is_enabled() && starts_with_key(row.item.label(), typed)
572                        });
573                    if found.is_some() {
574                        self.highlight(cx, found, false);
575                    }
576                    Handled::Yes
577                }
578                _ => Handled::Yes,
579            },
580            Event::FocusGained | Event::FocusLost => {
581                cx.request_paint();
582                Handled::Yes
583            }
584            _ => Handled::No,
585        }
586    }
587
588    /// A menu takes the keyboard when it opens, except a submenu opened by hovering, which
589    /// takes it when the pointer comes over it (the parent keeps it meanwhile).
590    fn accepts_focus(&self) -> bool {
591        !self.nested || self.from_keyboard || self.entered
592    }
593
594    fn accessibility(&self, cx: &PaintCx<'_>, node: &mut AccessNode) {
595        node.set_role(Role::Menu);
596        let rect = cx.rect();
597        for (index, row) in self.rows.iter().enumerate() {
598            let role = match row.item.kind {
599                ItemKind::Separator => continue,
600                ItemKind::Command {
601                    checked: Some(_), ..
602                } => Role::MenuItemCheckBox,
603                _ => Role::MenuItem,
604            };
605            let mut item = AccessNode::new(role);
606            item.set_label(row.item.label());
607            item.set_bounds(self.row_rect(rect, index));
608            item.set_disabled(!row.item.is_enabled());
609            match &row.item.kind {
610                ItemKind::Command {
611                    checked: Some(checked),
612                    ..
613                } => item.set_checked(*checked),
614                ItemKind::Submenu { .. } => {
615                    let open = self
616                        .open
617                        .is_some_and(|(at, id)| at == index && cx.is_popup_open(id));
618                    item.set_expanded(open);
619                }
620                _ => {}
621            }
622            if row.item.is_enabled() {
623                item.add_action(AccessAction::Click);
624            }
625            // Lossless: a menu has far fewer than 2^32 entries.
626            node.push_part(index as u32, item);
627        }
628        if let Some(index) = self.highlighted {
629            // Lossless: as above.
630            node.set_focused_part(index as u32);
631        }
632    }
633
634    fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
635        let index = request.part.map(|part| part as usize);
636        match (request.action, index) {
637            (AccessAction::Click, Some(index)) if index < self.rows.len() => {
638                self.highlight(cx, Some(index), false);
639                self.choose(cx, index, true);
640                Handled::Yes
641            }
642            _ => Handled::No,
643        }
644    }
645}
646
647/// A menu bar title, as laid out.
648#[derive(Debug)]
649struct Title {
650    text: Text,
651    items: Vec<MenuItem>,
652    /// Left and right, from the bar's left edge, in physical pixels.
653    left: f32,
654    right: f32,
655}
656
657/// A row of menu titles: a press on one opens its [`Menu`] below it, and while one is open
658/// the pointer coming over another title switches to it. Left and Right switch menus from
659/// the keyboard. The bar is not in the Tab order: give it the keyboard with
660/// [`MenuBar::activate`] (on F10, say).
661#[derive(Debug, Default)]
662pub struct MenuBar {
663    titles: Vec<Title>,
664    highlighted: Option<usize>,
665    /// The menu open, and the title it belongs to.
666    open: Option<(usize, WidgetId)>,
667}
668
669impl MenuBar {
670    /// A bar without menus.
671    pub fn new() -> Self {
672        Self::default()
673    }
674
675    /// The bar with a menu titled `title` of `items` after the others.
676    #[must_use]
677    pub fn with_menu(
678        mut self,
679        title: impl Into<String>,
680        items: impl IntoIterator<Item = MenuItem>,
681    ) -> Self {
682        self.titles.push(Title {
683            text: Text::new(title),
684            items: items.into_iter().collect(),
685            left: 0.0,
686            right: 0.0,
687        });
688        self
689    }
690
691    /// The style for a bar across the top of a window.
692    pub fn style() -> Style {
693        Style::row().shrink(0.0)
694    }
695
696    /// Gives the bar the keyboard, its first title highlighted: Left and Right move, Down,
697    /// Enter or Space open, Escape lets go.
698    pub fn activate(&mut self, cx: &mut EventCx<'_>) {
699        if !self.titles.is_empty() {
700            self.highlighted = Some(0);
701        }
702        cx.request_focus();
703        cx.request_paint();
704    }
705
706    /// Replaces the entries of menu number `index` (a check mark that follows a setting);
707    /// shown from the next time the menu opens. `false` if there is no such menu.
708    pub fn set_items(&mut self, index: usize, items: impl IntoIterator<Item = MenuItem>) -> bool {
709        match self.titles.get_mut(index) {
710            Some(title) => {
711                title.items = items.into_iter().collect();
712                true
713            }
714            None => false,
715        }
716    }
717
718    /// The title of the menu open, by index.
719    pub fn open_menu(&self) -> Option<usize> {
720        self.open.map(|(index, _)| index)
721    }
722
723    fn title_at(&self, rect: Rect, at: Point) -> Option<usize> {
724        if !rect.contains(at) {
725            return None;
726        }
727        let x = at.x - rect.left();
728        self.titles.iter().position(|t| x >= t.left && x < t.right)
729    }
730
731    fn title_rect(&self, rect: Rect, index: usize) -> Rect {
732        let title = &self.titles[index];
733        Rect::from_edges(
734            rect.left() + title.left,
735            rect.top(),
736            rect.left() + title.right,
737            rect.bottom(),
738        )
739    }
740
741    /// Opens title `index`'s menu below it, closing another open one.
742    fn open(&mut self, cx: &mut EventCx<'_>, index: usize, from_keyboard: bool) {
743        if let Some((_, id)) = self.open.take() {
744            cx.close_popup(id);
745        }
746        let mut menu = Menu::new(self.titles[index].items.iter().cloned());
747        if from_keyboard {
748            menu = menu.highlighting_first();
749        }
750        let anchor = Anchor::below(self.title_rect(cx.rect(), index));
751        if let Ok(id) = cx.open_popup(menu, anchor, Menu::style()) {
752            self.open = Some((index, id));
753        }
754        self.highlighted = Some(index);
755        cx.request_paint();
756    }
757
758    fn close(&mut self, cx: &mut EventCx<'_>) {
759        if let Some((_, id)) = self.open.take() {
760            cx.close_popup(id);
761        }
762        cx.request_paint();
763    }
764
765    /// The title `step` away from `from`, wrapping around.
766    fn neighbour(&self, from: usize, step: isize) -> usize {
767        // Lossless: a bar has few titles, and the result is in 0..count.
768        let count = self.titles.len() as isize;
769        (from as isize + step).rem_euclid(count) as usize
770    }
771}
772
773impl Widget for MenuBar {
774    fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
775        let color = cx.theme().text;
776        let padding = cx.px(TITLE_PADDING_X).round();
777        let (mut x, mut height) = (0.0_f32, 0.0_f32);
778        for title in &mut self.titles {
779            let size = shaped(&mut title.text, cx, color);
780            title.left = x;
781            x += (size.width + 2.0 * padding).round();
782            title.right = x;
783            height = height.max(size.height);
784        }
785        Size::new(x.ceil(), (height + 2.0 * cx.px(TITLE_PADDING_Y)).ceil())
786    }
787
788    fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
789        let theme = cx.theme();
790        let rect = cx.rect();
791        canvas.fill_rect(rect, theme.surface);
792        let line = cx.px(1.0).round();
793        canvas.fill_rect(
794            Rect::from_edges(
795                rect.left(),
796                rect.bottom() - line,
797                rect.right(),
798                rect.bottom(),
799            ),
800            theme.border,
801        );
802        let open = self
803            .open
804            .filter(|(_, id)| cx.is_popup_open(*id))
805            .map(|(index, _)| index);
806        for (index, title) in self.titles.iter().enumerate() {
807            let area = self.title_rect(rect, index);
808            if open == Some(index) {
809                canvas.fill_rect(area, theme.pressed);
810            } else if self.highlighted == Some(index) && (cx.is_hovered() || cx.is_focused()) {
811                canvas.fill_rect(area, theme.hover);
812            }
813            let size = title.text.size();
814            let at = crate::paint::centered(area, size.width, size.height);
815            title.text.paint(canvas, at);
816        }
817    }
818
819    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
820        if let Some((_, id)) = self.open {
821            if !cx.is_popup_open(id) {
822                self.open = None;
823            }
824        }
825        match event {
826            Event::Pointer(pointer) => match pointer.kind {
827                PointerKind::Enter | PointerKind::Move => {
828                    let at = self.title_at(cx.rect(), pointer.position);
829                    match (at, self.open) {
830                        (Some(index), Some((open, _))) if index != open => {
831                            self.open(cx, index, false);
832                        }
833                        (None, Some(_)) => {}
834                        _ => {
835                            if self.highlighted != at {
836                                self.highlighted = at;
837                                cx.request_paint();
838                            }
839                        }
840                    }
841                    Handled::Yes
842                }
843                PointerKind::Leave => {
844                    if self.open.is_none() && !cx.is_focused() {
845                        self.highlighted = None;
846                        cx.request_paint();
847                    }
848                    Handled::Yes
849                }
850                PointerKind::Down(PointerButton::Primary) => {
851                    match (self.title_at(cx.rect(), pointer.position), self.open) {
852                        (Some(index), Some((open, _))) if index == open => self.close(cx),
853                        (Some(index), _) => self.open(cx, index, false),
854                        (None, _) => self.close(cx),
855                    }
856                    Handled::Yes
857                }
858                PointerKind::Down(_) | PointerKind::Up(_) => Handled::Yes,
859                _ => Handled::No,
860            },
861            Event::Key(key) if key.pressed => {
862                let Key::Named(named) = key.key else {
863                    return Handled::No;
864                };
865                let Some(at) = self.open.map(|(index, _)| index).or(self.highlighted) else {
866                    return Handled::No;
867                };
868                match named {
869                    NamedKey::ArrowLeft | NamedKey::ArrowRight => {
870                        let step = if named == NamedKey::ArrowLeft { -1 } else { 1 };
871                        let next = self.neighbour(at, step);
872                        if self.open.is_some() {
873                            self.open(cx, next, true);
874                        } else {
875                            self.highlighted = Some(next);
876                            cx.request_paint();
877                        }
878                        Handled::Yes
879                    }
880                    NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space => {
881                        self.open(cx, at, true);
882                        Handled::Yes
883                    }
884                    NamedKey::Escape => {
885                        self.highlighted = None;
886                        cx.release_focus();
887                        cx.request_paint();
888                        Handled::Yes
889                    }
890                    _ => Handled::No,
891                }
892            }
893            Event::FocusLost => {
894                if self.open.is_none() {
895                    self.highlighted = None;
896                }
897                cx.request_paint();
898                Handled::Yes
899            }
900            _ => Handled::No,
901        }
902    }
903
904    fn accessibility(&self, cx: &PaintCx<'_>, node: &mut AccessNode) {
905        node.set_role(Role::MenuBar);
906        let rect = cx.rect();
907        let open = self
908            .open
909            .filter(|(_, id)| cx.is_popup_open(*id))
910            .map(|(index, _)| index);
911        for (index, title) in self.titles.iter().enumerate() {
912            let mut item = AccessNode::new(Role::MenuItem);
913            item.set_label(title.text.as_str());
914            item.set_bounds(self.title_rect(rect, index));
915            item.set_expanded(open == Some(index));
916            item.add_action(AccessAction::Click);
917            // Lossless: a bar has few titles.
918            node.push_part(index as u32, item);
919        }
920        if let Some(index) = self.highlighted.filter(|_| cx.is_focused()) {
921            // Lossless: as above.
922            node.set_focused_part(index as u32);
923        }
924    }
925
926    fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
927        let index = request.part.map(|part| part as usize);
928        match (request.action, index) {
929            (AccessAction::Click, Some(index)) if index < self.titles.len() => {
930                let open = self
931                    .open
932                    .is_some_and(|(at, id)| at == index && cx.is_popup_open(id));
933                if open {
934                    self.close(cx);
935                } else {
936                    self.open(cx, index, true);
937                }
938                Handled::Yes
939            }
940            _ => Handled::No,
941        }
942    }
943}