Skip to main content

iced_cube/application/
menubar.rs

1//! A horizontal strip of top-level menus, such as File, Edit, View and
2//! Help, along the top of a desktop window.
3//!
4//! Each menu is a [dropdown menu](mod@crate::overlay::dropdown_menu): the same
5//! entries, rows, checkbox and radio items, submenus and shortcut hints.
6//! [`State`] holds the menus, which one is open and which title has the
7//! keyboard focus. Once a menu is open, moving the pointer onto another
8//! title switches to that menu.
9//!
10//! The bar resolves its own keys while a menu is open: Left and Right move
11//! between menus (opening and closing submenus first), and the rest go to
12//! the open menu. While the bar is closed, the app routes key presses from
13//! [`keys::subscription`] through [`State::key_event`]: F10 or Alt focuses
14//! the bar, Left and Right move along it, Down opens the focused menu,
15//! Escape leaves it, and Alt with a menu's mnemonic letter opens that menu.
16//!
17//! ```no_run
18//! use iced::{Element, Subscription};
19//! use iced_cube::dropdown_menu::{item, separator};
20//! use iced_cube::keys;
21//! use iced_cube::menubar::{self, Output, menu, menubar};
22//!
23//! #[derive(Debug, Clone)]
24//! enum Message {
25//!     Menubar(menubar::Event<&'static str>),
26//!     Key(keys::Event),
27//! }
28//!
29//! struct App {
30//!     menubar: menubar::State<&'static str>,
31//! }
32//!
33//! impl App {
34//!     fn new() -> Self {
35//!         let menubar = menubar::State::new([
36//!             menu("File", [item("new", "New").shortcut("Ctrl+N"), separator(), item("quit", "Quit")]),
37//!             menu("Help", [item("about", "About")]),
38//!         ]);
39//!         Self { menubar }
40//!     }
41//!
42//!     fn update(&mut self, message: Message) {
43//!         let event = match message {
44//!             Message::Menubar(event) => Some(event),
45//!             Message::Key(key) => self.menubar.key_event(
46//!                 &menubar::default_keymap(),
47//!                 &iced_cube::dropdown_menu::default_keymap(),
48//!                 &key,
49//!             ),
50//!         };
51//!         if let Some(Output::Activated(id)) = event.and_then(|event| self.menubar.update(event)) {
52//!             println!("Run {id}");
53//!         }
54//!     }
55//!
56//!     fn view(&self) -> Element<'_, Message> {
57//!         menubar(&self.menubar).on_event(Message::Menubar).into()
58//!     }
59//!
60//!     fn subscription(&self) -> Subscription<Message> {
61//!         keys::subscription().map(Message::Key)
62//!     }
63//! }
64//! ```
65
66use std::rc::Rc;
67
68use iced::keyboard::{Key, Modifiers, key::Named};
69use iced::widget::text::{LineHeight, Span};
70use iced::widget::{self, column, container, mouse_area, rich_text, row, span, text};
71use iced::{Background, Border, Color, Element, Length, Padding, Shadow};
72
73use crate::keys::{self, Chord, Keymap};
74use crate::overlay::anchored::{Align, Placement, Side, anchored};
75use crate::overlay::dropdown_menu::{self, Entry, Kind};
76use crate::overlay::menu::LINE_HEIGHT;
77use crate::theme::{Tokens, radius, space, text_size};
78
79pub use crate::overlay::dropdown_menu::Output;
80
81/// Padding inside a menu title: 4 pixels above and below, 8 at the sides.
82pub const TRIGGER_PADDING: Padding = Padding {
83    top: 4.0,
84    bottom: 4.0,
85    left: space::SM,
86    right: space::SM,
87};
88
89/// One top-level menu: its title and its entries.
90#[derive(Debug, Clone, PartialEq)]
91pub struct Menu<Id> {
92    title: String,
93    mnemonic: Option<char>,
94    state: dropdown_menu::State<Id>,
95}
96
97/// A menu titled `title` holding dropdown menu `entries`. Its mnemonic is
98/// the first letter of the title unless [`Menu::mnemonic`] sets another.
99pub fn menu<Id: Copy + PartialEq>(
100    title: impl Into<String>,
101    entries: impl IntoIterator<Item = Entry<Id>>,
102) -> Menu<Id> {
103    Menu {
104        title: title.into(),
105        mnemonic: None,
106        state: dropdown_menu::State::new(entries),
107    }
108}
109
110impl<Id> Menu<Id> {
111    /// The letter that opens this menu with Alt, or on its own while the
112    /// bar has focus. It is underlined in the title while the bar has focus.
113    pub fn mnemonic(mut self, letter: char) -> Self {
114        self.mnemonic = Some(letter);
115        self
116    }
117
118    pub fn title(&self) -> &str {
119        &self.title
120    }
121
122    /// The menu's entries and whether it is open.
123    pub fn state(&self) -> &dropdown_menu::State<Id> {
124        &self.state
125    }
126
127    /// The mnemonic in lower case: the one set, or the title's first letter.
128    pub fn key(&self) -> Option<char> {
129        self.mnemonic
130            .or_else(|| self.title.chars().find(|c| c.is_alphanumeric()))
131            .and_then(|c| c.to_lowercase().next())
132    }
133}
134
135/// Changes to a menubar.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Event<Id> {
138    /// A title was pressed: opens its menu, or closes it when it is open.
139    Toggle(usize),
140    /// The pointer moved onto a title. Switches to its menu while another
141    /// menu is open, and does nothing otherwise.
142    Hover(usize),
143    /// An event for the menu at this position.
144    Menu(usize, dropdown_menu::Event<Id>),
145    /// Focuses the first title, or leaves the bar when it has focus.
146    Focus,
147    /// Moves to the next title, wrapping, and opens its menu if one is open.
148    Next,
149    /// Moves to the previous title, wrapping, and opens its menu if one is
150    /// open.
151    Previous,
152    /// Opens the focused title's menu on its first item.
153    Open,
154    /// Opens the menu at this position on its first item, as its mnemonic
155    /// does.
156    OpenMenu(usize),
157    /// Closes the open menu and keeps the focus on its title, or leaves the
158    /// bar when no menu is open.
159    Close,
160    /// Closes everything and leaves the bar, as a press outside does.
161    Dismiss,
162}
163
164/// The menus, which one is open and which title has the keyboard focus.
165#[derive(Debug, Clone, PartialEq)]
166pub struct State<Id> {
167    menus: Vec<Menu<Id>>,
168    focused: Option<usize>,
169    keyboard: bool,
170}
171
172impl<Id: Copy + PartialEq> State<Id> {
173    /// A closed menubar. Item ids must be unique across every menu.
174    pub fn new(menus: impl IntoIterator<Item = Menu<Id>>) -> Self {
175        Self {
176            menus: menus.into_iter().collect(),
177            focused: None,
178            keyboard: false,
179        }
180    }
181
182    /// Whether the bar was last used from the keyboard, which underlines
183    /// the mnemonics while it has focus.
184    pub fn shows_mnemonics(&self) -> bool {
185        self.keyboard && self.is_focused()
186    }
187
188    pub fn menus(&self) -> &[Menu<Id>] {
189        &self.menus
190    }
191
192    /// The position of the open menu.
193    pub fn open(&self) -> Option<usize> {
194        self.menus.iter().position(|menu| menu.state.is_open())
195    }
196
197    pub fn is_open(&self) -> bool {
198        self.open().is_some()
199    }
200
201    /// The title with the keyboard focus. The open menu's title keeps it.
202    pub fn focused(&self) -> Option<usize> {
203        self.focused
204    }
205
206    /// Whether the bar takes the keys that move along it.
207    pub fn is_focused(&self) -> bool {
208        self.focused.is_some()
209    }
210
211    /// The highlighted item in the open menu.
212    pub fn highlighted(&self) -> Option<Id> {
213        self.menus.iter().find_map(|menu| menu.state.highlighted())
214    }
215
216    /// Whether a checkbox or radio item in any menu is checked.
217    pub fn is_checked(&self, id: Id) -> bool {
218        self.menus.iter().any(|menu| menu.state.is_checked(id))
219    }
220
221    /// Checks or unchecks an item, in whichever menu holds it.
222    pub fn set_checked(&mut self, id: Id, checked: bool) {
223        for menu in &mut self.menus {
224            if menu.state.item(id).is_some() {
225                menu.state.set_checked(id, checked);
226            }
227        }
228    }
229
230    /// Enables or disables an item, in whichever menu holds it.
231    pub fn set_disabled(&mut self, id: Id, disabled: bool) {
232        for menu in &mut self.menus {
233            if menu.state.item(id).is_some() {
234                menu.state.set_disabled(id, disabled);
235            }
236        }
237    }
238
239    /// Applies an event and returns what was chosen, if anything. Choosing
240    /// an item closes the menu and leaves the bar.
241    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
242        match event {
243            Event::Toggle(_) | Event::Hover(_) => self.keyboard = false,
244            Event::Focus | Event::Next | Event::Previous | Event::Open | Event::OpenMenu(_) => {
245                self.keyboard = true;
246            }
247            Event::Menu(..) | Event::Close | Event::Dismiss => {}
248        }
249        match event {
250            Event::Toggle(index) if self.open() == Some(index) => self.dismiss(),
251            Event::Toggle(index) => self.switch(index, dropdown_menu::Event::Open),
252            Event::Hover(index) => {
253                if self.open().is_some_and(|open| open != index) {
254                    self.switch(index, dropdown_menu::Event::Open);
255                }
256            }
257            Event::Menu(_, dropdown_menu::Event::Close) => self.close(),
258            Event::Menu(index, event) => return self.forward(index, event),
259            Event::Focus if self.is_open() || self.is_focused() => self.dismiss(),
260            Event::Focus => self.focused = (!self.menus.is_empty()).then_some(0),
261            Event::Next => self.step(true),
262            Event::Previous => self.step(false),
263            Event::Open => {
264                if let Some(index) = self.focused {
265                    self.switch(index, dropdown_menu::Event::First);
266                }
267            }
268            Event::OpenMenu(index) => self.switch(index, dropdown_menu::Event::First),
269            Event::Close => self.close(),
270            Event::Dismiss => self.dismiss(),
271        }
272        None
273    }
274
275    fn forward(&mut self, index: usize, event: dropdown_menu::Event<Id>) -> Option<Output<Id>> {
276        let menu = self.menus.get_mut(index)?;
277        let output = menu.state.update(event);
278        if output.is_some() {
279            self.dismiss();
280            return output;
281        }
282        if menu.state.is_open() {
283            self.close_others(index);
284            self.focused = Some(index);
285        }
286        None
287    }
288
289    /// Opens the menu at `index` with `event`, closing any other.
290    fn switch(&mut self, index: usize, event: dropdown_menu::Event<Id>) {
291        let Some(menu) = self.menus.get_mut(index) else {
292            return;
293        };
294        let _ = menu.state.update(dropdown_menu::Event::Close);
295        let _ = menu.state.update(event);
296        self.close_others(index);
297        self.focused = Some(index);
298    }
299
300    fn close_others(&mut self, keep: usize) {
301        for (index, menu) in self.menus.iter_mut().enumerate() {
302            if index != keep {
303                let _ = menu.state.update(dropdown_menu::Event::Close);
304            }
305        }
306    }
307
308    fn close_all(&mut self) {
309        for menu in &mut self.menus {
310            let _ = menu.state.update(dropdown_menu::Event::Close);
311        }
312    }
313
314    fn close(&mut self) {
315        if self.is_open() {
316            self.close_all();
317        } else {
318            self.focused = None;
319        }
320    }
321
322    fn dismiss(&mut self) {
323        self.close_all();
324        self.focused = None;
325    }
326
327    fn step(&mut self, forward: bool) {
328        let len = self.menus.len();
329        let Some(current) = self.open().or(self.focused) else {
330            return;
331        };
332        if len == 0 {
333            return;
334        }
335        let target = if forward {
336            (current + 1) % len
337        } else {
338            (current + len - 1) % len
339        };
340        if self.is_open() {
341            self.switch(target, dropdown_menu::Event::First);
342        } else {
343            self.focused = Some(target);
344        }
345    }
346
347    /// Turns a key press into an event. While a menu is open, the bar's
348    /// keymap goes first (Left and Right open and close submenus before
349    /// they move between menus), then `menu_keymap` and typeahead. While
350    /// closed, the bar claims [`Action::Focus`], its mnemonics with Alt,
351    /// and, only while it has focus, its other actions and bare mnemonics.
352    pub fn key_event(
353        &self,
354        keymap: &Keymap<Action>,
355        menu_keymap: &Keymap<dropdown_menu::Action>,
356        key: &keys::Event,
357    ) -> Option<Event<Id>> {
358        let action = resolve(keymap, key);
359        if let Some(open) = self.open() {
360            if let Some(event) = action.and_then(|action| action.event(self)) {
361                return Some(event);
362            }
363            let menu = self.menus.get(open)?;
364            return menu
365                .state
366                .key_event(menu_keymap, key)
367                .map(|event| Event::Menu(open, event));
368        }
369        if let Some(event) = action.and_then(|action| action.event(self)) {
370            return Some(event);
371        }
372        self.mnemonic(key).map(Event::OpenMenu)
373    }
374
375    /// The menu whose mnemonic a key press types: with Alt at any time, or
376    /// on its own while the bar has focus.
377    fn mnemonic(&self, key: &keys::Event) -> Option<usize> {
378        let alone = key.modifiers.is_empty() || key.modifiers == Modifiers::SHIFT;
379        let wanted = key.modifiers == Modifiers::ALT || (alone && self.is_focused());
380        if !wanted {
381            return None;
382        }
383        let Key::Character(typed) = &key.key else {
384            return None;
385        };
386        let typed = typed.chars().next()?.to_lowercase().next()?;
387        self.menus.iter().position(|menu| menu.key() == Some(typed))
388    }
389
390    /// Whether the deepest highlighted item of the open menu opens a submenu.
391    fn on_submenu(&self) -> bool {
392        let Some(id) = self.highlighted() else {
393            return false;
394        };
395        self.menus.iter().any(|menu| {
396            menu.state
397                .item(id)
398                .is_some_and(|item| !item.disabled && matches!(item.kind, Kind::Submenu(_)))
399        })
400    }
401
402    fn open_depth(&self) -> usize {
403        self.open()
404            .and_then(|open| self.menus.get(open))
405            .map_or(0, |menu| menu.state.depth())
406    }
407}
408
409/// Resolves a key press through a keymap. A bare modifier key, such as
410/// Alt, arrives with its own modifier held, so that modifier is ignored.
411fn resolve<A: Clone>(keymap: &Keymap<A>, key: &keys::Event) -> Option<A> {
412    let modifiers = match key.key {
413        Key::Named(Named::Alt) => key.modifiers.difference(Modifiers::ALT),
414        Key::Named(Named::Control) => key.modifiers.difference(Modifiers::CTRL),
415        Key::Named(Named::Shift) => key.modifiers.difference(Modifiers::SHIFT),
416        _ => key.modifiers,
417    };
418    keymap.resolve(&key.key, modifiers)
419}
420
421/// What a menubar keyboard shortcut does.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
423pub enum Action {
424    /// Focuses the bar, or leaves it.
425    Focus,
426    NextMenu,
427    PreviousMenu,
428    /// Opens the focused title's menu.
429    Open,
430    Close,
431}
432
433impl Action {
434    /// The [`Event`] this action sends to `state`, or `None` when it does
435    /// nothing, so the key can go elsewhere: while a menu is open, Open is
436    /// left to the menu and Left and Right work its submenus first; while
437    /// the bar is closed and unfocused, only Focus does anything.
438    pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
439        if let Some(open) = state.open() {
440            return match self {
441                Action::Focus => Some(Event::Dismiss),
442                Action::NextMenu if state.on_submenu() => {
443                    Some(Event::Menu(open, dropdown_menu::Event::OpenSubmenu))
444                }
445                Action::NextMenu => Some(Event::Next),
446                Action::PreviousMenu if state.open_depth() > 1 => {
447                    Some(Event::Menu(open, dropdown_menu::Event::CloseSubmenu))
448                }
449                Action::PreviousMenu => Some(Event::Previous),
450                Action::Close if state.open_depth() > 1 => {
451                    Some(Event::Menu(open, dropdown_menu::Event::CloseSubmenu))
452                }
453                Action::Close => Some(Event::Close),
454                Action::Open => None,
455            };
456        }
457        if !state.is_focused() {
458            return (self == Action::Focus).then_some(Event::Focus);
459        }
460        Some(match self {
461            Action::Focus => Event::Focus,
462            Action::NextMenu => Event::Next,
463            Action::PreviousMenu => Event::Previous,
464            Action::Open => Event::Open,
465            Action::Close => Event::Close,
466        })
467    }
468}
469
470impl keys::Action for Action {
471    const ALL: &'static [Self] = &[
472        Action::Focus,
473        Action::NextMenu,
474        Action::PreviousMenu,
475        Action::Open,
476        Action::Close,
477    ];
478
479    fn defaults() -> Keymap<Self> {
480        default_keymap()
481    }
482
483    fn name(self) -> &'static str {
484        match self {
485            Action::Focus => "Focus",
486            Action::NextMenu => "NextMenu",
487            Action::PreviousMenu => "PreviousMenu",
488            Action::Open => "Open",
489            Action::Close => "Close",
490        }
491    }
492
493    fn description(self) -> &'static str {
494        match self {
495            Action::Focus => "Focuses the first menu title, or leaves the bar and closes its menu.",
496            Action::NextMenu => {
497                "Moves to the next menu, or opens the highlighted submenu inside an open menu."
498            }
499            Action::PreviousMenu => {
500                "Moves to the previous menu, or closes the open submenu inside an open menu."
501            }
502            Action::Open => "Opens the focused title's menu on its first item.",
503            Action::Close => "Closes the open submenu or menu, or leaves the bar.",
504        }
505    }
506}
507
508/// The default menubar shortcuts:
509///
510/// | Keys | Action |
511/// | --- | --- |
512/// | `F10`, `Alt` | [`Action::Focus`] |
513/// | `ArrowRight` | [`Action::NextMenu`] |
514/// | `ArrowLeft` | [`Action::PreviousMenu`] |
515/// | `ArrowDown`, `Enter`, `Space` | [`Action::Open`] |
516/// | `Escape` | [`Action::Close`] |
517///
518/// While a menu is open the bar resolves these itself, before the open
519/// menu's own keymap. While the bar is closed, only Focus claims a key
520/// unless the bar has focus, so the arrows stay free for the rest of the
521/// app. Alt with a menu's mnemonic letter opens that menu at any time.
522pub fn default_keymap() -> Keymap<Action> {
523    Keymap::new()
524        .bind(Chord::named(Named::F10), Action::Focus)
525        .bind(Chord::named(Named::Alt), Action::Focus)
526        .bind(Chord::named(Named::ArrowRight), Action::NextMenu)
527        .bind(Chord::named(Named::ArrowLeft), Action::PreviousMenu)
528        .bind(Chord::named(Named::ArrowDown), Action::Open)
529        .bind(Chord::named(Named::Enter), Action::Open)
530        .bind(Chord::named(Named::Space), Action::Open)
531        .bind(Chord::named(Named::Escape), Action::Close)
532}
533
534/// How a menu title is drawn.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
536pub enum TriggerStatus {
537    #[default]
538    Idle,
539    Hovered,
540    /// Its menu is open, or it has the keyboard focus.
541    Active,
542    Disabled,
543}
544
545impl TriggerStatus {
546    pub const ALL: [TriggerStatus; 4] = [
547        TriggerStatus::Idle,
548        TriggerStatus::Hovered,
549        TriggerStatus::Active,
550        TriggerStatus::Disabled,
551    ];
552}
553
554/// The style of a menu title: transparent at rest, the accent under the
555/// pointer, while open and while it has the keyboard focus.
556pub fn trigger_style(tokens: &Tokens, status: TriggerStatus) -> widget::button::Style {
557    let (background, text_color) = match status {
558        TriggerStatus::Idle => (None, tokens.foreground),
559        TriggerStatus::Hovered | TriggerStatus::Active => {
560            (Some(tokens.accent), tokens.accent_foreground)
561        }
562        TriggerStatus::Disabled => (None, tokens.muted_foreground),
563    };
564    widget::button::Style {
565        background: background.map(Background::Color),
566        text_color,
567        border: Border {
568            color: Color::TRANSPARENT,
569            width: 0.0,
570            radius: radius::SM.into(),
571        },
572        shadow: Shadow::default(),
573        snap: true,
574    }
575}
576
577/// The style of the bar behind the titles.
578pub fn bar_style(tokens: &Tokens) -> container::Style {
579    container::Style {
580        background: Some(Background::Color(tokens.background)),
581        text_color: Some(tokens.foreground),
582        ..container::Style::default()
583    }
584}
585
586fn trigger_status(status: widget::button::Status, active: bool) -> TriggerStatus {
587    match status {
588        widget::button::Status::Disabled => TriggerStatus::Disabled,
589        _ if active => TriggerStatus::Active,
590        widget::button::Status::Hovered | widget::button::Status::Pressed => TriggerStatus::Hovered,
591        widget::button::Status::Active => TriggerStatus::Idle,
592    }
593}
594
595type OnEvent<'a, Id, Message> = Rc<dyn Fn(Event<Id>) -> Message + 'a>;
596
597/// A menubar builder. Convert it into an [`Element`] to render.
598pub struct Menubar<'a, Id, Message> {
599    state: &'a State<Id>,
600    width: f32,
601    keymap: Keymap<Action>,
602    menu_keymap: Keymap<dropdown_menu::Action>,
603    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
604}
605
606impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Menubar<'_, Id, Message> {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        f.debug_struct("Menubar")
609            .field("state", self.state)
610            .field("width", &self.width)
611            .field("keymap", &self.keymap)
612            .field("menu_keymap", &self.menu_keymap)
613            .finish_non_exhaustive()
614    }
615}
616
617/// Renders the titles of `state`, with the open menu below its title.
618/// Without [`on_event`](Menubar::on_event) every title renders disabled.
619pub fn menubar<'a, Id, Message>(state: &'a State<Id>) -> Menubar<'a, Id, Message> {
620    Menubar {
621        state,
622        width: dropdown_menu::WIDTH,
623        keymap: default_keymap(),
624        menu_keymap: dropdown_menu::default_keymap(),
625        on_event: None,
626    }
627}
628
629impl<'a, Id, Message> Menubar<'a, Id, Message> {
630    /// Width of each menu and its submenus.
631    pub fn width(mut self, width: f32) -> Self {
632        self.width = width;
633        self
634    }
635
636    /// Replaces the [`default_keymap`] the bar resolves while a menu is open.
637    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
638        self.keymap = keymap;
639        self
640    }
641
642    /// Replaces the [dropdown menu keys](dropdown_menu::default_keymap) the
643    /// open menu resolves after the bar's own.
644    pub fn menu_keymap(mut self, keymap: Keymap<dropdown_menu::Action>) -> Self {
645        self.menu_keymap = keymap;
646        self
647    }
648
649    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
650        self.on_event = Some(Box::new(on_event));
651        self
652    }
653}
654
655impl<'a, Id, Message> From<Menubar<'a, Id, Message>> for Element<'a, Message>
656where
657    Id: Copy + PartialEq + 'a,
658    Message: Clone + 'a,
659{
660    fn from(menubar: Menubar<'a, Id, Message>) -> Self {
661        let Menubar {
662            state,
663            width,
664            keymap,
665            menu_keymap,
666            on_event,
667        } = menubar;
668        let on_event: Option<OnEvent<'a, Id, Message>> = on_event.map(Rc::from);
669        let keymaps = Rc::new((keymap, menu_keymap));
670
671        let titles = state.menus.iter().enumerate().map(|(index, menu)| {
672            title(state, index, menu, on_event.clone(), keymaps.clone(), width)
673        });
674
675        column![
676            container(row(titles).spacing(2))
677                .padding([space::XS, space::XS])
678                .width(Length::Fill)
679                .style(|theme| bar_style(&Tokens::of(theme))),
680            crate::application::edge(),
681        ]
682        .width(Length::Fill)
683        .into()
684    }
685}
686
687/// One title with its menu floating below it while open.
688fn title<'a, Id, Message>(
689    state: &'a State<Id>,
690    index: usize,
691    menu: &'a Menu<Id>,
692    on_event: Option<OnEvent<'a, Id, Message>>,
693    keymaps: Rc<(Keymap<Action>, Keymap<dropdown_menu::Action>)>,
694    width: f32,
695) -> Element<'a, Message>
696where
697    Id: Copy + PartialEq + 'a,
698    Message: Clone + 'a,
699{
700    let open = menu.state.is_open();
701    let active = open || (!state.is_open() && state.focused == Some(index));
702    let label = title_label(menu, state.shows_mnemonics());
703    let trigger = widget::button(label)
704        .padding(TRIGGER_PADDING)
705        .style(move |theme, status| {
706            trigger_style(&Tokens::of(theme), trigger_status(status, active))
707        })
708        .on_press_maybe(
709            on_event
710                .as_ref()
711                .map(|on_event| on_event(Event::Toggle(index))),
712        );
713
714    let mut anchor = mouse_area(trigger);
715    if let Some(on_event) = on_event.as_ref().filter(|_| state.is_open() && !open) {
716        anchor = anchor.on_enter(on_event(Event::Hover(index)));
717    }
718
719    let on_menu = on_event
720        .clone()
721        .map(|on_event| move |event| on_event(Event::Menu(index, event)));
722    let panel = open.then(|| {
723        dropdown_menu::panel(
724            &menu.state,
725            on_menu
726                .as_ref()
727                .map(|f| f as &dyn Fn(dropdown_menu::Event<Id>) -> Message),
728            width,
729        )
730    });
731    let floating = anchored(anchor)
732        .content(panel)
733        .placement(Placement::new(Side::Bottom, Align::Start))
734        .dismiss_keys([]);
735    let Some(on_event) = on_event.filter(|_| open) else {
736        return floating.into();
737    };
738    floating
739        .on_dismiss(on_event(Event::Dismiss))
740        .on_key(move |key| {
741            let (keymap, menu_keymap) = &*keymaps;
742            state.key_event(keymap, menu_keymap, key).map(&*on_event)
743        })
744        .into()
745}
746
747/// The title text, with its mnemonic underlined while the bar has focus.
748fn title_label<'a, Message: 'a, Id>(menu: &Menu<Id>, underline: bool) -> Element<'a, Message> {
749    let plain = || {
750        text(menu.title.clone())
751            .size(text_size::SM)
752            .line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
753            .wrapping(text::Wrapping::None)
754            .into()
755    };
756    if !underline {
757        return plain();
758    }
759    let Some((before, letter, after)) = split_mnemonic(&menu.title, menu.key()) else {
760        return plain();
761    };
762    let spans: Vec<Span<'a, ()>> = vec![span(before), span(letter).underline(true), span(after)];
763    rich_text(spans)
764        .size(text_size::SM)
765        .line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
766        .wrapping(text::Wrapping::None)
767        .into()
768}
769
770/// Splits a title around the first letter matching its mnemonic.
771fn split_mnemonic(title: &str, key: Option<char>) -> Option<(String, String, String)> {
772    let key = key?;
773    let (start, letter) = title
774        .char_indices()
775        .find(|(_, c)| c.to_lowercase().next() == Some(key))?;
776    let end = start + letter.len_utf8();
777    Some((
778        title.get(..start)?.to_owned(),
779        title.get(start..end)?.to_owned(),
780        title.get(end..)?.to_owned(),
781    ))
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::overlay::dropdown_menu::{checkbox_item, item, separator, submenu};
788    use crate::theme::{dark, light};
789
790    const NEW: u8 = 1;
791    const QUIT: u8 = 2;
792    const UNDO: u8 = 3;
793    const RECENT: u8 = 4;
794    const LAST: u8 = 5;
795    const GRID: u8 = 6;
796    const ABOUT: u8 = 7;
797
798    fn state() -> State<u8> {
799        State::new([
800            menu(
801                "File",
802                [
803                    item(NEW, "New").shortcut("Ctrl+N"),
804                    submenu(RECENT, "Open recent", [item(LAST, "notes.md")]),
805                    separator(),
806                    item(QUIT, "Quit"),
807                ],
808            ),
809            menu("Edit", [item(UNDO, "Undo")]),
810            menu("View", [checkbox_item(GRID, "Show grid", true)]),
811            menu("Help", [item(ABOUT, "About")]).mnemonic('p'),
812        ])
813    }
814
815    fn key(key: Key, modifiers: Modifiers) -> keys::Event {
816        keys::Event { key, modifiers }
817    }
818
819    fn named(named: Named) -> keys::Event {
820        key(Key::Named(named), Modifiers::empty())
821    }
822
823    fn resolve_key(state: &State<u8>, event: &keys::Event) -> Option<Event<u8>> {
824        state.key_event(&default_keymap(), &dropdown_menu::default_keymap(), event)
825    }
826
827    #[test]
828    fn starts_closed_and_unfocused() {
829        let state = state();
830        assert_eq!(state.open(), None);
831        assert!(!state.is_open() && !state.is_focused());
832        assert_eq!(state.menus().len(), 4);
833        assert_eq!(state.menus()[0].title(), "File");
834    }
835
836    #[test]
837    fn mnemonics_default_to_the_first_letter() {
838        let state = state();
839        let keys: Vec<_> = state.menus().iter().map(Menu::key).collect();
840        assert_eq!(keys, [Some('f'), Some('e'), Some('v'), Some('p')]);
841        let empty: Menu<u8> = menu("", []);
842        assert_eq!(empty.key(), None);
843    }
844
845    #[test]
846    fn toggle_opens_and_closes_a_menu() {
847        let mut state = state();
848        let _ = state.update(Event::Toggle(1));
849        assert_eq!(state.open(), Some(1));
850        assert_eq!(state.focused(), Some(1));
851        assert_eq!(
852            state.highlighted(),
853            None,
854            "the pointer opens without a highlight"
855        );
856        let _ = state.update(Event::Toggle(1));
857        assert!(!state.is_open() && !state.is_focused());
858    }
859
860    #[test]
861    fn toggling_another_title_switches_menus() {
862        let mut state = state();
863        let _ = state.update(Event::Toggle(0));
864        let _ = state.update(Event::Toggle(2));
865        assert_eq!(state.open(), Some(2));
866        assert!(!state.menus()[0].state().is_open());
867    }
868
869    #[test]
870    fn hovering_switches_only_while_a_menu_is_open() {
871        let mut state = state();
872        let _ = state.update(Event::Hover(1));
873        assert!(!state.is_open(), "hover alone opens nothing");
874
875        let _ = state.update(Event::Toggle(0));
876        let _ = state.update(Event::Hover(1));
877        assert_eq!(state.open(), Some(1));
878        let _ = state.update(Event::Hover(1));
879        assert_eq!(state.open(), Some(1));
880        let _ = state.update(Event::Hover(9));
881        assert_eq!(state.open(), Some(1), "unknown titles are ignored");
882    }
883
884    #[test]
885    fn choosing_an_item_returns_it_and_leaves_the_bar() {
886        let mut state = state();
887        let _ = state.update(Event::Toggle(0));
888        assert_eq!(
889            state.update(Event::Menu(0, dropdown_menu::Event::Activate(NEW))),
890            Some(Output::Activated(NEW))
891        );
892        assert!(!state.is_open() && !state.is_focused());
893
894        let _ = state.update(Event::Toggle(2));
895        assert_eq!(
896            state.update(Event::Menu(2, dropdown_menu::Event::Activate(GRID))),
897            Some(Output::Toggled(GRID, false))
898        );
899        assert!(!state.is_checked(GRID));
900    }
901
902    #[test]
903    fn menu_events_for_unknown_menus_do_nothing() {
904        let mut state = state();
905        assert_eq!(
906            state.update(Event::Menu(9, dropdown_menu::Event::Open)),
907            None
908        );
909        assert!(!state.is_open());
910    }
911
912    #[test]
913    fn a_menu_opened_through_its_own_events_closes_the_others() {
914        let mut state = state();
915        let _ = state.update(Event::Toggle(0));
916        let _ = state.update(Event::Menu(1, dropdown_menu::Event::Next));
917        assert_eq!(state.open(), Some(1));
918        assert_eq!(state.focused(), Some(1));
919        assert!(!state.menus()[0].state().is_open());
920    }
921
922    #[test]
923    fn focus_toggles_the_bar() {
924        let mut state = state();
925        let _ = state.update(Event::Focus);
926        assert_eq!(state.focused(), Some(0));
927        assert!(!state.is_open());
928        let _ = state.update(Event::Focus);
929        assert!(!state.is_focused());
930
931        let mut empty: State<u8> = State::new([]);
932        let _ = empty.update(Event::Focus);
933        assert!(!empty.is_focused());
934        let _ = empty.update(Event::Next);
935        assert!(!empty.is_focused());
936    }
937
938    #[test]
939    fn mnemonics_show_only_while_the_keyboard_drives_the_bar() {
940        let mut state = state();
941        assert!(!state.shows_mnemonics());
942        let _ = state.update(Event::Focus);
943        assert!(state.shows_mnemonics());
944        let _ = state.update(Event::Open);
945        assert!(state.shows_mnemonics());
946        let _ = state.update(Event::Hover(2));
947        assert!(!state.shows_mnemonics(), "the pointer takes over");
948        let _ = state.update(Event::Dismiss);
949        let _ = state.update(Event::Toggle(0));
950        assert!(!state.shows_mnemonics());
951    }
952
953    #[test]
954    fn next_and_previous_move_the_focus_and_wrap() {
955        let mut state = state();
956        let _ = state.update(Event::Next);
957        assert!(!state.is_focused(), "an unfocused bar does not move");
958        let _ = state.update(Event::Focus);
959        let _ = state.update(Event::Previous);
960        assert_eq!(state.focused(), Some(3));
961        let _ = state.update(Event::Next);
962        assert_eq!(state.focused(), Some(0));
963        assert!(!state.is_open());
964    }
965
966    #[test]
967    fn next_with_a_menu_open_opens_the_next_menu_on_its_first_item() {
968        let mut state = state();
969        let _ = state.update(Event::Toggle(0));
970        let _ = state.update(Event::Next);
971        assert_eq!(state.open(), Some(1));
972        assert_eq!(state.highlighted(), Some(UNDO));
973        let _ = state.update(Event::Previous);
974        let _ = state.update(Event::Previous);
975        assert_eq!(state.open(), Some(3));
976        assert_eq!(state.highlighted(), Some(ABOUT));
977    }
978
979    #[test]
980    fn open_opens_the_focused_menu_on_its_first_item() {
981        let mut state = state();
982        let _ = state.update(Event::Open);
983        assert!(!state.is_open(), "nothing is focused");
984        let _ = state.update(Event::Focus);
985        let _ = state.update(Event::Next);
986        let _ = state.update(Event::Open);
987        assert_eq!(state.open(), Some(1));
988        assert_eq!(state.highlighted(), Some(UNDO));
989    }
990
991    #[test]
992    fn open_menu_opens_by_position() {
993        let mut state = state();
994        let _ = state.update(Event::OpenMenu(2));
995        assert_eq!(state.open(), Some(2));
996        assert_eq!(state.highlighted(), Some(GRID));
997        let _ = state.update(Event::OpenMenu(7));
998        assert_eq!(state.open(), Some(2));
999    }
1000
1001    #[test]
1002    fn close_returns_to_the_title_then_leaves() {
1003        let mut state = state();
1004        let _ = state.update(Event::OpenMenu(1));
1005        let _ = state.update(Event::Close);
1006        assert!(!state.is_open());
1007        assert_eq!(state.focused(), Some(1));
1008        let _ = state.update(Event::Close);
1009        assert!(!state.is_focused());
1010
1011        let _ = state.update(Event::OpenMenu(1));
1012        let _ = state.update(Event::Menu(1, dropdown_menu::Event::Close));
1013        assert_eq!(state.focused(), Some(1), "a menu's own Close acts the same");
1014    }
1015
1016    #[test]
1017    fn dismiss_closes_everything() {
1018        let mut state = state();
1019        let _ = state.update(Event::OpenMenu(1));
1020        let _ = state.update(Event::Dismiss);
1021        assert!(!state.is_open() && !state.is_focused());
1022    }
1023
1024    #[test]
1025    fn checked_and_disabled_items_are_found_in_any_menu() {
1026        let mut state = state();
1027        assert!(state.is_checked(GRID));
1028        state.set_checked(GRID, false);
1029        assert!(!state.is_checked(GRID));
1030        state.set_disabled(UNDO, true);
1031        assert!(
1032            state.menus()[1]
1033                .state()
1034                .item(UNDO)
1035                .is_some_and(|item| item.disabled)
1036        );
1037        let _ = state.update(Event::OpenMenu(1));
1038        assert_eq!(state.highlighted(), None);
1039    }
1040
1041    #[test]
1042    fn a_closed_unfocused_bar_claims_only_focus_and_alt_mnemonics() {
1043        let state = state();
1044        for named in [
1045            Named::ArrowDown,
1046            Named::ArrowLeft,
1047            Named::ArrowRight,
1048            Named::Escape,
1049        ] {
1050            assert_eq!(resolve_key(&state, &self::named(named)), None, "{named:?}");
1051        }
1052        assert_eq!(
1053            resolve_key(&state, &self::named(Named::F10)),
1054            Some(Event::Focus)
1055        );
1056        assert_eq!(
1057            resolve_key(&state, &key(Key::Named(Named::Alt), Modifiers::ALT)),
1058            Some(Event::Focus),
1059            "Alt arrives with its own modifier held"
1060        );
1061        assert_eq!(
1062            resolve_key(&state, &key(Key::Named(Named::Alt), Modifiers::empty())),
1063            Some(Event::Focus)
1064        );
1065        assert_eq!(
1066            resolve_key(&state, &key(Key::Character("e".into()), Modifiers::ALT)),
1067            Some(Event::OpenMenu(1))
1068        );
1069        assert_eq!(
1070            resolve_key(&state, &key(Key::Character("P".into()), Modifiers::ALT)),
1071            Some(Event::OpenMenu(3))
1072        );
1073        assert_eq!(
1074            resolve_key(&state, &key(Key::Character("e".into()), Modifiers::empty())),
1075            None,
1076            "bare letters need a focused bar"
1077        );
1078        assert_eq!(
1079            resolve_key(&state, &key(Key::Character("z".into()), Modifiers::ALT)),
1080            None,
1081            "letters without a menu stay free"
1082        );
1083    }
1084
1085    #[test]
1086    fn a_focused_bar_moves_opens_and_takes_bare_mnemonics() {
1087        let mut state = state();
1088        let _ = state.update(Event::Focus);
1089        assert_eq!(
1090            resolve_key(&state, &named(Named::ArrowRight)),
1091            Some(Event::Next)
1092        );
1093        assert_eq!(
1094            resolve_key(&state, &named(Named::ArrowLeft)),
1095            Some(Event::Previous)
1096        );
1097        assert_eq!(
1098            resolve_key(&state, &named(Named::ArrowDown)),
1099            Some(Event::Open)
1100        );
1101        assert_eq!(resolve_key(&state, &named(Named::Enter)), Some(Event::Open));
1102        assert_eq!(
1103            resolve_key(&state, &named(Named::Escape)),
1104            Some(Event::Close)
1105        );
1106        assert_eq!(resolve_key(&state, &named(Named::F10)), Some(Event::Focus));
1107        assert_eq!(
1108            resolve_key(&state, &key(Key::Character("v".into()), Modifiers::empty())),
1109            Some(Event::OpenMenu(2))
1110        );
1111        assert_eq!(resolve_key(&state, &named(Named::Tab)), None);
1112    }
1113
1114    #[test]
1115    fn an_open_menu_sends_left_and_right_to_submenus_first() {
1116        let mut state = state();
1117        let _ = state.update(Event::OpenMenu(0));
1118        assert_eq!(state.highlighted(), Some(NEW));
1119        assert_eq!(
1120            resolve_key(&state, &named(Named::ArrowRight)),
1121            Some(Event::Next)
1122        );
1123        assert_eq!(
1124            resolve_key(&state, &named(Named::ArrowLeft)),
1125            Some(Event::Previous)
1126        );
1127
1128        let _ = state.update(Event::Menu(0, dropdown_menu::Event::Next));
1129        assert_eq!(state.highlighted(), Some(RECENT));
1130        let right = resolve_key(&state, &named(Named::ArrowRight));
1131        assert_eq!(
1132            right,
1133            Some(Event::Menu(0, dropdown_menu::Event::OpenSubmenu))
1134        );
1135        let _ = state.update(Event::Menu(0, dropdown_menu::Event::OpenSubmenu));
1136        assert_eq!(state.highlighted(), Some(LAST));
1137        assert_eq!(
1138            resolve_key(&state, &named(Named::ArrowLeft)),
1139            Some(Event::Menu(0, dropdown_menu::Event::CloseSubmenu))
1140        );
1141        assert_eq!(
1142            resolve_key(&state, &named(Named::Escape)),
1143            Some(Event::Menu(0, dropdown_menu::Event::CloseSubmenu))
1144        );
1145    }
1146
1147    #[test]
1148    fn an_open_menu_leaves_the_rest_to_the_menu_keymap() {
1149        let mut state = state();
1150        let _ = state.update(Event::OpenMenu(0));
1151        assert_eq!(
1152            resolve_key(&state, &named(Named::ArrowDown)),
1153            Some(Event::Menu(0, dropdown_menu::Event::Next))
1154        );
1155        assert_eq!(
1156            resolve_key(&state, &named(Named::Enter)),
1157            Some(Event::Menu(0, dropdown_menu::Event::ActivateHighlighted))
1158        );
1159        assert_eq!(
1160            resolve_key(&state, &key(Key::Character("q".into()), Modifiers::empty())),
1161            Some(Event::Menu(0, dropdown_menu::Event::Typeahead('q')))
1162        );
1163        assert_eq!(
1164            resolve_key(&state, &named(Named::Escape)),
1165            Some(Event::Close)
1166        );
1167        assert_eq!(
1168            resolve_key(&state, &named(Named::F10)),
1169            Some(Event::Dismiss)
1170        );
1171    }
1172
1173    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1174        let chord: Chord = chord.parse().unwrap();
1175        keymap.resolve(chord.key(), chord.modifiers())
1176    }
1177
1178    #[test]
1179    fn default_keymap_covers_every_action() {
1180        let keymap = default_keymap();
1181        assert_eq!(press(&keymap, "F10"), Some(Action::Focus));
1182        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::NextMenu));
1183        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::PreviousMenu));
1184        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Open));
1185        assert_eq!(press(&keymap, "Enter"), Some(Action::Open));
1186        assert_eq!(press(&keymap, "Space"), Some(Action::Open));
1187        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1188        assert_eq!(
1189            keymap.resolve(&Key::Named(Named::Alt), Modifiers::empty()),
1190            Some(Action::Focus)
1191        );
1192        for action in <Action as keys::Action>::ALL {
1193            assert!(!keymap.chords(action).is_empty(), "{action:?}");
1194        }
1195        assert_eq!(Keymap::<Action>::defaults(), keymap);
1196    }
1197
1198    #[test]
1199    fn keymap_overrides_and_unbinding() {
1200        let keymap = default_keymap()
1201            .unbind(&Chord::named(Named::Alt))
1202            .bind("Ctrl+M".parse().unwrap(), Action::Focus);
1203        let state = state();
1204        let alt = key(Key::Named(Named::Alt), Modifiers::ALT);
1205        let menus = dropdown_menu::default_keymap();
1206        assert_eq!(state.key_event(&keymap, &menus, &alt), None);
1207        let ctrl_m = key(Key::Character("m".into()), Modifiers::CTRL);
1208        assert_eq!(
1209            state.key_event(&keymap, &menus, &ctrl_m),
1210            Some(Event::Focus)
1211        );
1212        assert_eq!(
1213            state.key_event(&Keymap::new(), &menus, &named(Named::F10)),
1214            None
1215        );
1216    }
1217
1218    #[test]
1219    fn a_closed_unfocused_bar_maps_only_focus() {
1220        let state = state();
1221        assert_eq!(Action::Focus.event(&state), Some(Event::Focus));
1222        for &action in <Action as keys::Action>::ALL {
1223            if action != Action::Focus {
1224                assert_eq!(action.event(&state), None, "{action:?}");
1225            }
1226        }
1227    }
1228
1229    #[test]
1230    fn an_open_bar_leaves_open_to_the_menu() {
1231        let mut state = state();
1232        let _ = state.update(Event::OpenMenu(1));
1233        assert_eq!(Action::Open.event(&state), None);
1234        assert_eq!(Action::Focus.event(&state), Some(Event::Dismiss));
1235        assert_eq!(Action::Close.event(&state), Some(Event::Close));
1236    }
1237
1238    #[test]
1239    fn every_action_has_a_name_and_a_sentence() {
1240        for &action in <Action as keys::Action>::ALL {
1241            assert!(!keys::Action::name(action).is_empty());
1242            assert!(keys::Action::description(action).ends_with('.'));
1243        }
1244    }
1245
1246    #[test]
1247    fn builder_defaults_and_overrides() {
1248        let state = state();
1249        let bar: Menubar<'_, u8, ()> = menubar(&state);
1250        assert_eq!(bar.width, dropdown_menu::WIDTH);
1251        assert_eq!(bar.keymap, default_keymap());
1252        assert_eq!(bar.menu_keymap, dropdown_menu::default_keymap());
1253        assert!(bar.on_event.is_none());
1254
1255        let bar: Menubar<'_, u8, ()> = menubar(&state)
1256            .width(300.0)
1257            .keymap(Keymap::new())
1258            .menu_keymap(Keymap::new())
1259            .on_event(|_| ());
1260        assert_eq!(bar.width, 300.0);
1261        assert!(bar.keymap.is_empty() && bar.menu_keymap.is_empty());
1262        assert!(bar.on_event.is_some());
1263    }
1264
1265    #[test]
1266    fn titles_use_the_accent_while_hovered_or_active() {
1267        for theme in [light(), dark()] {
1268            let tokens = Tokens::of(&theme);
1269            let idle = trigger_style(&tokens, TriggerStatus::Idle);
1270            assert_eq!(idle.background, None);
1271            assert_eq!(idle.text_color, tokens.foreground);
1272            for status in [TriggerStatus::Hovered, TriggerStatus::Active] {
1273                let style = trigger_style(&tokens, status);
1274                assert_eq!(style.background, Some(Background::Color(tokens.accent)));
1275                assert_eq!(style.text_color, tokens.accent_foreground);
1276                assert_eq!(style.border.radius, radius::SM.into());
1277            }
1278            let disabled = trigger_style(&tokens, TriggerStatus::Disabled);
1279            assert_eq!(disabled.text_color, tokens.muted_foreground);
1280            assert_eq!(disabled.background, None);
1281            assert_eq!(
1282                bar_style(&tokens).background,
1283                Some(Background::Color(tokens.background))
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn an_active_title_stays_active_under_the_pointer() {
1290        use widget::button::Status;
1291        assert_eq!(trigger_status(Status::Hovered, true), TriggerStatus::Active);
1292        assert_eq!(
1293            trigger_status(Status::Hovered, false),
1294            TriggerStatus::Hovered
1295        );
1296        assert_eq!(
1297            trigger_status(Status::Pressed, false),
1298            TriggerStatus::Hovered
1299        );
1300        assert_eq!(trigger_status(Status::Active, false), TriggerStatus::Idle);
1301        assert_eq!(
1302            trigger_status(Status::Disabled, true),
1303            TriggerStatus::Disabled
1304        );
1305    }
1306
1307    #[test]
1308    fn mnemonics_split_the_title_around_the_first_match() {
1309        assert_eq!(
1310            split_mnemonic("Help", Some('p')),
1311            Some(("Hel".into(), "p".into(), String::new()))
1312        );
1313        assert_eq!(
1314            split_mnemonic("File", Some('f')),
1315            Some((String::new(), "F".into(), "ile".into()))
1316        );
1317        assert_eq!(split_mnemonic("File", Some('x')), None);
1318        assert_eq!(split_mnemonic("File", None), None);
1319    }
1320}