Skip to main content

iced_cube/overlay/
dropdown_menu.rs

1//! A menu of actions that opens from a trigger button.
2//!
3//! [`State`] owns the entries (items, checkbox and radio items, submenus,
4//! group labels and separators), whether the menu is open, and which row is
5//! highlighted at each open level. Its [`update`](State::update) handles
6//! pointer and keyboard navigation and returns an [`Output`] when an item
7//! is chosen. The context menu uses the same model and rows.
8//!
9//! An open menu resolves its own keys through a [`Keymap`] of [`Action`]s,
10//! so the app needs no subscription while it is open; see
11//! [`default_keymap`]. A closed menu claims no keys: it opens from its
12//! trigger, or from a chord the app binds to [`Action::Open`] and routes
13//! through [`State::key_event`]. Rows are drawn in the shared
14//! [menu look](crate::overlay::menu).
15
16use std::rc::Rc;
17
18use iced::Element;
19use iced::keyboard::{Key, key::Named};
20use iced::widget::column;
21
22use crate::icon::Glyph;
23use crate::keys::{self, Chord, Keymap};
24use crate::overlay::anchored::{Align, Placement, Side, anchored};
25use crate::overlay::menu::{self, Leading, Parts, RowStatus, Trailing};
26use crate::theme::space;
27
28/// Default menu width in logical pixels.
29pub const WIDTH: f32 = 224.0;
30
31/// What an item does when chosen.
32#[derive(Debug, Clone, PartialEq)]
33pub enum Kind<Id> {
34    /// A plain action.
35    Action,
36    /// A check mark that toggles.
37    Checkbox(bool),
38    /// One choice among the radio items next to it. A run of adjacent
39    /// radio items forms one group.
40    Radio(bool),
41    /// Opens a nested menu.
42    Submenu(Vec<Entry<Id>>),
43}
44
45/// A row that can be highlighted and chosen.
46#[derive(Debug, Clone, PartialEq)]
47pub struct Item<Id> {
48    pub id: Id,
49    pub label: String,
50    pub icon: Option<Glyph>,
51    /// A hint such as `Ctrl+S`, shown on the right. It does not bind the keys.
52    pub shortcut: Option<String>,
53    pub disabled: bool,
54    pub destructive: bool,
55    pub kind: Kind<Id>,
56}
57
58impl<Id> Item<Id> {
59    fn enabled(&self) -> bool {
60        !self.disabled
61    }
62
63    fn submenu(&self) -> Option<&[Entry<Id>]> {
64        match &self.kind {
65            Kind::Submenu(entries) => Some(entries),
66            _ => None,
67        }
68    }
69}
70
71/// One line of a menu.
72#[derive(Debug, Clone, PartialEq)]
73pub enum Entry<Id> {
74    Item(Item<Id>),
75    /// A heading for the items below it.
76    Label(String),
77    Separator,
78}
79
80fn new_item<Id>(id: Id, label: impl Into<String>, kind: Kind<Id>) -> Entry<Id> {
81    Entry::Item(Item {
82        id,
83        label: label.into(),
84        icon: None,
85        shortcut: None,
86        disabled: false,
87        destructive: false,
88        kind,
89    })
90}
91
92/// An action item.
93pub fn item<Id>(id: Id, label: impl Into<String>) -> Entry<Id> {
94    new_item(id, label, Kind::Action)
95}
96
97/// An item with a check mark that toggles when chosen.
98pub fn checkbox_item<Id>(id: Id, label: impl Into<String>, checked: bool) -> Entry<Id> {
99    new_item(id, label, Kind::Checkbox(checked))
100}
101
102/// An item that checks itself and unchecks the radio items next to it.
103pub fn radio_item<Id>(id: Id, label: impl Into<String>, checked: bool) -> Entry<Id> {
104    new_item(id, label, Kind::Radio(checked))
105}
106
107/// An item that opens `entries` in a nested menu to its right.
108pub fn submenu<Id>(
109    id: Id,
110    label: impl Into<String>,
111    entries: impl IntoIterator<Item = Entry<Id>>,
112) -> Entry<Id> {
113    new_item(id, label, Kind::Submenu(entries.into_iter().collect()))
114}
115
116/// A heading for a group of items.
117pub fn group_label<Id>(label: impl Into<String>) -> Entry<Id> {
118    Entry::Label(label.into())
119}
120
121/// A line between groups.
122pub fn separator<Id>() -> Entry<Id> {
123    Entry::Separator
124}
125
126impl<Id> Entry<Id> {
127    /// Adds a leading Lucide icon. Ignored on labels and separators, as are
128    /// the other item settings.
129    pub fn icon(self, glyph: Glyph) -> Self {
130        self.with_item(|item| item.icon = Some(glyph))
131    }
132
133    pub fn shortcut(self, hint: impl Into<String>) -> Self {
134        let hint = hint.into();
135        self.with_item(|item| item.shortcut = Some(hint))
136    }
137
138    pub fn disabled(self, disabled: bool) -> Self {
139        self.with_item(|item| item.disabled = disabled)
140    }
141
142    /// Draws the item in the destructive colour, for actions such as delete.
143    pub fn destructive(self, destructive: bool) -> Self {
144        self.with_item(|item| item.destructive = destructive)
145    }
146
147    pub fn as_item(&self) -> Option<&Item<Id>> {
148        match self {
149            Entry::Item(item) => Some(item),
150            _ => None,
151        }
152    }
153
154    fn with_item(mut self, change: impl FnOnce(&mut Item<Id>)) -> Self {
155        if let Entry::Item(item) = &mut self {
156            change(item);
157        }
158        self
159    }
160
161    fn enabled_item(&self) -> Option<&Item<Id>> {
162        self.as_item().filter(|item| item.enabled())
163    }
164}
165
166/// Changes to a menu.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Event<Id> {
169    /// Opens with nothing highlighted, as a pointer does.
170    Open,
171    Close,
172    Toggle,
173    /// The pointer moved onto an item. A submenu item opens its submenu.
174    Highlight(Id),
175    /// Highlights the next enabled item, wrapping. Opens a closed menu on
176    /// the first item.
177    Next,
178    /// Highlights the previous enabled item, wrapping. Opens a closed menu
179    /// on the last item.
180    Previous,
181    First,
182    Last,
183    /// Opens the highlighted submenu and highlights its first item.
184    OpenSubmenu,
185    /// Closes the innermost submenu.
186    CloseSubmenu,
187    /// Chooses an item, as a click does.
188    Activate(Id),
189    /// Chooses the highlighted item, or opens its submenu. Opens a closed
190    /// menu on the first item.
191    ActivateHighlighted,
192    /// Highlights the next item whose label starts with this character.
193    Typeahead(char),
194}
195
196/// What choosing an item did. The menu closes afterwards.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum Output<Id> {
199    /// An action item was chosen.
200    Activated(Id),
201    /// A checkbox item changed to this value.
202    Toggled(Id, bool),
203    /// A radio item was chosen.
204    Selected(Id),
205}
206
207/// The entries of a menu, whether it is open and what is highlighted.
208#[derive(Debug, Clone, PartialEq)]
209pub struct State<Id> {
210    entries: Vec<Entry<Id>>,
211    /// One slot per open level: the root menu, then each open submenu.
212    levels: Vec<Option<usize>>,
213}
214
215impl<Id: Copy + PartialEq> State<Id> {
216    /// A closed menu. Item ids must be unique across submenus.
217    pub fn new(entries: impl IntoIterator<Item = Entry<Id>>) -> Self {
218        Self {
219            entries: entries.into_iter().collect(),
220            levels: Vec::new(),
221        }
222    }
223
224    pub fn entries(&self) -> &[Entry<Id>] {
225        &self.entries
226    }
227
228    pub fn is_open(&self) -> bool {
229        !self.levels.is_empty()
230    }
231
232    /// How many menus are open: 0 when closed, 1 for the root menu, plus
233    /// one per open submenu.
234    pub fn depth(&self) -> usize {
235        self.levels.len()
236    }
237
238    /// The highlighted item in the menu that has keyboard focus.
239    pub fn highlighted(&self) -> Option<Id> {
240        let path = self.active_path()?;
241        self.item_at(&path).map(|item| item.id)
242    }
243
244    /// The highlighted row index in the menu `depth` levels deep.
245    pub fn highlighted_at(&self, depth: usize) -> Option<usize> {
246        self.levels.get(depth).copied().flatten()
247    }
248
249    pub fn item(&self, id: Id) -> Option<&Item<Id>> {
250        let path = path_of(&self.entries, id)?;
251        self.item_at(&path)
252    }
253
254    /// Whether a checkbox or radio item is checked.
255    pub fn is_checked(&self, id: Id) -> bool {
256        self.item(id)
257            .is_some_and(|item| matches!(item.kind, Kind::Checkbox(true) | Kind::Radio(true)))
258    }
259
260    /// Checks or unchecks an item. Checking a radio item unchecks its group.
261    pub fn set_checked(&mut self, id: Id, checked: bool) {
262        let Some(path) = path_of(&self.entries, id) else {
263            return;
264        };
265        let Some((&index, parents)) = path.split_last() else {
266            return;
267        };
268        let Some(siblings) = entries_mut(&mut self.entries, parents) else {
269            return;
270        };
271        let is_radio = matches!(
272            siblings.get(index),
273            Some(Entry::Item(Item {
274                kind: Kind::Radio(_),
275                ..
276            }))
277        );
278        if is_radio && checked {
279            for sibling in radio_group(siblings, index) {
280                if let Some(Entry::Item(item)) = siblings.get_mut(sibling) {
281                    item.kind = Kind::Radio(false);
282                }
283            }
284        }
285        if let Some(Entry::Item(item)) = siblings.get_mut(index) {
286            match &mut item.kind {
287                Kind::Checkbox(value) | Kind::Radio(value) => *value = checked,
288                Kind::Action | Kind::Submenu(_) => {}
289            }
290        }
291    }
292
293    /// Enables or disables an item. A disabled item loses its highlight.
294    pub fn set_disabled(&mut self, id: Id, disabled: bool) {
295        let Some(path) = path_of(&self.entries, id) else {
296            return;
297        };
298        let Some((&index, parents)) = path.split_last() else {
299            return;
300        };
301        if let Some(Entry::Item(item)) =
302            entries_mut(&mut self.entries, parents).and_then(|entries| entries.get_mut(index))
303        {
304            item.disabled = disabled;
305        }
306        let highlighted = self
307            .levels
308            .iter()
309            .take(path.len())
310            .copied()
311            .eq(path.iter().copied().map(Some));
312        if disabled && highlighted {
313            self.levels.truncate(parents.len() + 1);
314            if let Some(level) = self.levels.get_mut(parents.len()) {
315                *level = None;
316            }
317        }
318    }
319
320    /// Applies an event and returns what was chosen, if anything.
321    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
322        match event {
323            Event::Open => self.open(),
324            Event::Close => self.levels.clear(),
325            Event::Toggle if self.is_open() => self.levels.clear(),
326            Event::Toggle => self.open(),
327            Event::Highlight(id) => self.highlight(id),
328            Event::Next | Event::First if !self.is_open() => self.open_on(Edge::First),
329            Event::Previous | Event::Last if !self.is_open() => self.open_on(Edge::Last),
330            Event::Next => self.step(Step::Next),
331            Event::Previous => self.step(Step::Previous),
332            Event::First => self.step(Step::Edge(Edge::First)),
333            Event::Last => self.step(Step::Edge(Edge::Last)),
334            Event::OpenSubmenu => self.open_submenu(),
335            Event::CloseSubmenu => self.close_submenu(),
336            Event::Typeahead(character) => self.typeahead(character),
337            Event::Activate(id) => return self.activate(id),
338            Event::ActivateHighlighted if !self.is_open() => self.open_on(Edge::First),
339            Event::ActivateHighlighted => return self.activate_highlighted(),
340        }
341        None
342    }
343
344    /// Turns a key press into an event: first through `keymap`, then, while
345    /// the menu is open, as typeahead for a letter or digit. An open menu
346    /// does this itself; an app calls it for a closed menu's
347    /// [`Action::Open`] chord.
348    pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event<Id>> {
349        if let Some(action) = keymap.resolve_event(key) {
350            return action.event(self);
351        }
352        if !self.is_open() {
353            return None;
354        }
355        typeahead(key).map(Event::Typeahead)
356    }
357
358    fn open(&mut self) {
359        if !self.is_open() {
360            self.levels.push(None);
361        }
362    }
363
364    fn open_on(&mut self, edge: Edge) {
365        self.levels = vec![edge.find(&self.entries)];
366    }
367
368    /// The deepest level with a highlight, or the root.
369    fn active_depth(&self) -> usize {
370        self.levels.iter().rposition(Option::is_some).unwrap_or(0)
371    }
372
373    fn active_path(&self) -> Option<Vec<usize>> {
374        let depth = self.active_depth();
375        self.levels.get(..=depth)?.iter().copied().collect()
376    }
377
378    fn entries_at(&self, depth: usize) -> Option<&[Entry<Id>]> {
379        let mut entries = self.entries.as_slice();
380        for level in self.levels.get(..depth)? {
381            entries = entries.get((*level)?)?.as_item()?.submenu()?;
382        }
383        Some(entries)
384    }
385
386    fn item_at(&self, path: &[usize]) -> Option<&Item<Id>> {
387        let (&index, parents) = path.split_last()?;
388        let mut entries = self.entries.as_slice();
389        for &parent in parents {
390            entries = entries.get(parent)?.as_item()?.submenu()?;
391        }
392        entries.get(index)?.as_item()
393    }
394
395    /// Moves the highlight in the active menu, closing deeper submenus.
396    fn step(&mut self, step: Step) {
397        let depth = self.active_depth();
398        let Some(entries) = self.entries_at(depth) else {
399            return;
400        };
401        let current = self.highlighted_at(depth);
402        let target = match step {
403            Step::Next => next_enabled(entries, current, true, |_| true),
404            Step::Previous => next_enabled(entries, current, false, |_| true),
405            Step::Edge(edge) => edge.find(entries),
406        };
407        self.set_highlight(depth, target);
408    }
409
410    fn typeahead(&mut self, character: char) {
411        let depth = self.active_depth();
412        let Some(entries) = self.entries_at(depth) else {
413            return;
414        };
415        let wanted = character.to_lowercase().collect::<String>();
416        let target = next_enabled(entries, self.highlighted_at(depth), true, |item| {
417            item.label.to_lowercase().starts_with(&wanted)
418        });
419        self.set_highlight(depth, target);
420    }
421
422    fn set_highlight(&mut self, depth: usize, target: Option<usize>) {
423        if target.is_none() {
424            return;
425        }
426        self.levels.truncate(depth + 1);
427        if let Some(level) = self.levels.get_mut(depth) {
428            *level = target;
429        }
430    }
431
432    fn highlight(&mut self, id: Id) {
433        let Some(path) = path_of(&self.entries, id) else {
434            return;
435        };
436        let Some(item) = self.item_at(&path).filter(|item| item.enabled()) else {
437            return;
438        };
439        let opens = item.submenu().is_some();
440        let target = some(&path);
441        if self.levels.len() > target.len() && self.levels.starts_with(&target) {
442            return;
443        }
444        self.levels = target;
445        if opens {
446            self.levels.push(None);
447        }
448    }
449
450    fn open_submenu(&mut self) {
451        let depth = self.active_depth();
452        let Some(path) = self.active_path() else {
453            return;
454        };
455        let Some(children) = self
456            .item_at(&path)
457            .filter(|item| item.enabled())
458            .and_then(Item::submenu)
459        else {
460            return;
461        };
462        let first = Edge::First.find(children);
463        self.levels.truncate(depth + 1);
464        self.levels.push(first);
465    }
466
467    fn close_submenu(&mut self) {
468        if self.levels.len() <= 1 {
469            return;
470        }
471        let keep = self.active_depth().max(1);
472        self.levels.truncate(keep);
473    }
474
475    fn activate(&mut self, id: Id) -> Option<Output<Id>> {
476        let path = path_of(&self.entries, id)?;
477        let item = self.item_at(&path).filter(|item| item.enabled())?;
478        if item.submenu().is_some() {
479            self.highlight(id);
480            return None;
481        }
482        self.choose(id)
483    }
484
485    fn activate_highlighted(&mut self) -> Option<Output<Id>> {
486        let path = self.active_path()?;
487        let item = self.item_at(&path).filter(|item| item.enabled())?;
488        if item.submenu().is_some() {
489            self.open_submenu();
490            return None;
491        }
492        let id = item.id;
493        self.choose(id)
494    }
495
496    /// Applies a chosen item and closes the menu.
497    fn choose(&mut self, id: Id) -> Option<Output<Id>> {
498        let output = match self.item(id)?.kind {
499            Kind::Action => Output::Activated(id),
500            Kind::Checkbox(checked) => {
501                self.set_checked(id, !checked);
502                Output::Toggled(id, !checked)
503            }
504            Kind::Radio(_) => {
505                self.set_checked(id, true);
506                Output::Selected(id)
507            }
508            Kind::Submenu(_) => return None,
509        };
510        self.levels.clear();
511        Some(output)
512    }
513}
514
515fn some(path: &[usize]) -> Vec<Option<usize>> {
516    path.iter().copied().map(Some).collect()
517}
518
519#[derive(Debug, Clone, Copy)]
520enum Edge {
521    First,
522    Last,
523}
524
525impl Edge {
526    fn find<Id>(self, entries: &[Entry<Id>]) -> Option<usize> {
527        let enabled = |(_, entry): &(usize, &Entry<Id>)| entry.enabled_item().is_some();
528        match self {
529            Edge::First => entries.iter().enumerate().find(enabled),
530            Edge::Last => entries.iter().enumerate().rev().find(enabled),
531        }
532        .map(|(index, _)| index)
533    }
534}
535
536#[derive(Debug, Clone, Copy)]
537enum Step {
538    Next,
539    Previous,
540    Edge(Edge),
541}
542
543/// The nearest enabled item after (or before) `from` that `accept`s,
544/// wrapping round. With nothing highlighted, the search starts at an end.
545fn next_enabled<Id>(
546    entries: &[Entry<Id>],
547    from: Option<usize>,
548    forward: bool,
549    accept: impl Fn(&Item<Id>) -> bool,
550) -> Option<usize> {
551    let len = entries.len();
552    if len == 0 {
553        return None;
554    }
555    let start = from.unwrap_or(if forward { len - 1 } else { 0 });
556    let offset = if forward { 1 } else { len - 1 };
557    (1..=len)
558        .map(|n| (start + offset * n) % len)
559        .find(|&index| {
560            entries
561                .get(index)
562                .and_then(Entry::enabled_item)
563                .is_some_and(&accept)
564        })
565}
566
567/// The index path to an item, through submenus.
568fn path_of<Id: Copy + PartialEq>(entries: &[Entry<Id>], id: Id) -> Option<Vec<usize>> {
569    entries.iter().enumerate().find_map(|(index, entry)| {
570        let item = entry.as_item()?;
571        if item.id == id {
572            return Some(vec![index]);
573        }
574        let mut path = path_of(item.submenu()?, id)?;
575        path.insert(0, index);
576        Some(path)
577    })
578}
579
580fn entries_mut<'a, Id>(
581    entries: &'a mut Vec<Entry<Id>>,
582    parents: &[usize],
583) -> Option<&'a mut Vec<Entry<Id>>> {
584    let Some((&first, rest)) = parents.split_first() else {
585        return Some(entries);
586    };
587    match entries.get_mut(first)? {
588        Entry::Item(Item {
589            kind: Kind::Submenu(children),
590            ..
591        }) => entries_mut(children, rest),
592        _ => None,
593    }
594}
595
596/// The indices of the run of radio items around `index`.
597fn radio_group<Id>(entries: &[Entry<Id>], index: usize) -> std::ops::Range<usize> {
598    let is_radio = |entry: &Entry<Id>| {
599        matches!(
600            entry,
601            Entry::Item(Item {
602                kind: Kind::Radio(_),
603                ..
604            })
605        )
606    };
607    let start = entries
608        .get(..index)
609        .and_then(|before| before.iter().rposition(|entry| !is_radio(entry)))
610        .map_or(0, |position| position + 1);
611    let end = entries
612        .get(index..)
613        .and_then(|after| after.iter().position(|entry| !is_radio(entry)))
614        .map_or(entries.len(), |position| index + position);
615    start..end
616}
617
618/// The character a key press types into the menu's typeahead: a letter or
619/// digit pressed without Ctrl, Alt or the logo key.
620pub fn typeahead(key: &keys::Event) -> Option<char> {
621    let modifiers = key.modifiers;
622    if modifiers.control() || modifiers.alt() || modifiers.logo() {
623        return None;
624    }
625    let Key::Character(text) = &key.key else {
626        return None;
627    };
628    let mut chars = text.chars();
629    let character = chars.next()?;
630    if chars.next().is_some() || !character.is_alphanumeric() {
631        return None;
632    }
633    Some(character)
634}
635
636/// What a menu keyboard shortcut does.
637#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
638pub enum Action {
639    /// Opens a closed menu on its first item. Not bound by default.
640    Open,
641    Next,
642    Previous,
643    First,
644    Last,
645    Activate,
646    Close,
647    OpenSubmenu,
648    CloseSubmenu,
649}
650
651impl Action {
652    /// The [`Event`] this action sends to `state`, or `None` when it does
653    /// nothing: only [`Action::Open`] works while the menu is closed, and
654    /// only the others while it is open. [`Action::Close`] closes just the
655    /// innermost submenu when one is open.
656    pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
657        if !state.is_open() {
658            return (self == Action::Open).then_some(Event::First);
659        }
660        match self {
661            Action::Open => None,
662            Action::Next => Some(Event::Next),
663            Action::Previous => Some(Event::Previous),
664            Action::First => Some(Event::First),
665            Action::Last => Some(Event::Last),
666            Action::Activate => Some(Event::ActivateHighlighted),
667            Action::Close if state.depth() > 1 => Some(Event::CloseSubmenu),
668            Action::Close => Some(Event::Close),
669            Action::OpenSubmenu => Some(Event::OpenSubmenu),
670            Action::CloseSubmenu => Some(Event::CloseSubmenu),
671        }
672    }
673}
674
675impl keys::Action for Action {
676    const ALL: &'static [Self] = &[
677        Action::Open,
678        Action::Next,
679        Action::Previous,
680        Action::First,
681        Action::Last,
682        Action::Activate,
683        Action::Close,
684        Action::OpenSubmenu,
685        Action::CloseSubmenu,
686    ];
687
688    fn defaults() -> Keymap<Self> {
689        default_keymap()
690    }
691
692    fn name(self) -> &'static str {
693        match self {
694            Action::Open => "Open",
695            Action::Next => "Next",
696            Action::Previous => "Previous",
697            Action::First => "First",
698            Action::Last => "Last",
699            Action::Activate => "Activate",
700            Action::Close => "Close",
701            Action::OpenSubmenu => "OpenSubmenu",
702            Action::CloseSubmenu => "CloseSubmenu",
703        }
704    }
705
706    fn description(self) -> &'static str {
707        match self {
708            Action::Open => "Opens the menu on its first item.",
709            Action::Next => "Highlights the next enabled item, wrapping.",
710            Action::Previous => "Highlights the previous enabled item, wrapping.",
711            Action::First => "Highlights the first enabled item.",
712            Action::Last => "Highlights the last enabled item.",
713            Action::Activate => "Chooses the highlighted item or opens its submenu.",
714            Action::Close => "Closes the innermost submenu, or the menu.",
715            Action::OpenSubmenu => "Opens the highlighted submenu.",
716            Action::CloseSubmenu => "Closes the innermost submenu.",
717        }
718    }
719}
720
721/// The default menu shortcuts:
722///
723/// | Keys | Action |
724/// | --- | --- |
725/// | `ArrowDown` | [`Action::Next`] |
726/// | `ArrowUp` | [`Action::Previous`] |
727/// | `Home` | [`Action::First`] |
728/// | `End` | [`Action::Last`] |
729/// | `Enter`, `Space` | [`Action::Activate`] |
730/// | `Escape` | [`Action::Close`] |
731/// | `ArrowRight` | [`Action::OpenSubmenu`] |
732/// | `ArrowLeft` | [`Action::CloseSubmenu`] |
733///
734/// Letters and digits jump to the next item starting with them. The open
735/// menu handles all of these itself, before anything underneath sees
736/// them; pass a changed keymap with [`DropdownMenu::keymap`]. A closed
737/// menu claims none of them. [`Action::Open`] has no default chord
738/// because a menu usually belongs to one button; bind one when a menu
739/// deserves an app-wide shortcut.
740pub fn default_keymap() -> Keymap<Action> {
741    Keymap::new()
742        .bind(Chord::named(Named::ArrowDown), Action::Next)
743        .bind(Chord::named(Named::ArrowUp), Action::Previous)
744        .bind(Chord::named(Named::Home), Action::First)
745        .bind(Chord::named(Named::End), Action::Last)
746        .bind(Chord::named(Named::Enter), Action::Activate)
747        .bind(Chord::named(Named::Space), Action::Activate)
748        .bind(Chord::named(Named::Escape), Action::Close)
749        .bind(Chord::named(Named::ArrowRight), Action::OpenSubmenu)
750        .bind(Chord::named(Named::ArrowLeft), Action::CloseSubmenu)
751}
752
753/// A dropdown menu builder. Convert it into an [`Element`] to render.
754pub struct DropdownMenu<'a, Id, Message> {
755    state: &'a State<Id>,
756    trigger: Element<'a, Message>,
757    placement: Placement,
758    width: f32,
759    keymap: Keymap<Action>,
760    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
761}
762
763impl<Id: std::fmt::Debug, Message> std::fmt::Debug for DropdownMenu<'_, Id, Message> {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        f.debug_struct("DropdownMenu")
766            .field("state", self.state)
767            .field("placement", &self.placement)
768            .field("width", &self.width)
769            .field("keymap", &self.keymap)
770            .finish_non_exhaustive()
771    }
772}
773
774/// Renders `trigger` with the menu of `state` below it while it is open.
775///
776/// The trigger opens the menu itself, usually a button whose message
777/// carries [`Event::Toggle`]. Without [`on_event`](DropdownMenu::on_event)
778/// every item renders disabled.
779pub fn dropdown_menu<'a, Id, Message>(
780    state: &'a State<Id>,
781    trigger: impl Into<Element<'a, Message>>,
782) -> DropdownMenu<'a, Id, Message> {
783    DropdownMenu {
784        state,
785        trigger: trigger.into(),
786        placement: Placement::new(Side::Bottom, Align::Start),
787        width: WIDTH,
788        keymap: default_keymap(),
789        on_event: None,
790    }
791}
792
793impl<'a, Id, Message> DropdownMenu<'a, Id, Message> {
794    /// Which side of the trigger the menu opens on. Defaults to the bottom.
795    pub fn side(mut self, side: Side) -> Self {
796        self.placement.side = side;
797        self
798    }
799
800    /// Defaults to the start, lining the menu up with the trigger's left edge.
801    pub fn align(mut self, align: Align) -> Self {
802        self.placement.align = align;
803        self
804    }
805
806    /// Space between the trigger and the menu.
807    pub fn gap(mut self, gap: f32) -> Self {
808        self.placement.gap = gap;
809        self
810    }
811
812    /// Width of the menu and its submenus.
813    pub fn width(mut self, width: f32) -> Self {
814        self.width = width;
815        self
816    }
817
818    /// Replaces the [`default_keymap`] the open menu resolves its keys with.
819    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
820        self.keymap = keymap;
821        self
822    }
823
824    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
825        self.on_event = Some(Box::new(on_event));
826        self
827    }
828}
829
830impl<'a, Id, Message> From<DropdownMenu<'a, Id, Message>> for Element<'a, Message>
831where
832    Id: Copy + PartialEq + 'a,
833    Message: Clone + 'a,
834{
835    fn from(menu: DropdownMenu<'a, Id, Message>) -> Self {
836        let DropdownMenu {
837            state,
838            trigger,
839            placement,
840            width,
841            keymap,
842            on_event,
843        } = menu;
844        let on_event: Option<Rc<dyn Fn(Event<Id>) -> Message + 'a>> = on_event.map(Rc::from);
845        let panel = state
846            .is_open()
847            .then(|| panel(state, on_event.as_deref(), width));
848        let anchored = anchored(trigger)
849            .content(panel)
850            .placement(placement)
851            .dismiss_keys([]);
852        let Some(on_event) = on_event.filter(|_| state.is_open()) else {
853            return anchored.into();
854        };
855        anchored
856            .on_dismiss(on_event(Event::Close))
857            .on_key(move |key| state.key_event(&keymap, key).map(&*on_event))
858            .into()
859    }
860}
861
862/// The open menu: the root panel, with open submenus floating beside
863/// their items.
864pub(crate) fn panel<'a, Id, Message>(
865    state: &'a State<Id>,
866    on_event: Option<&dyn Fn(Event<Id>) -> Message>,
867    width: f32,
868) -> Element<'a, Message>
869where
870    Id: Copy + PartialEq + 'a,
871    Message: Clone + 'a,
872{
873    level(state, &state.entries, 0, on_event, width)
874}
875
876fn level<'a, Id, Message>(
877    state: &'a State<Id>,
878    entries: &'a [Entry<Id>],
879    depth: usize,
880    on_event: Option<&dyn Fn(Event<Id>) -> Message>,
881    width: f32,
882) -> Element<'a, Message>
883where
884    Id: Copy + PartialEq + 'a,
885    Message: Clone + 'a,
886{
887    let highlighted = state.highlighted_at(depth);
888    let submenu_open = state.depth() > depth + 1;
889    let indented = has_marks(entries);
890    let slot = reserves_slot(entries);
891
892    let rows = entries
893        .iter()
894        .enumerate()
895        .map(|(index, entry)| match entry {
896            Entry::Separator => menu::separator(),
897            Entry::Label(label) => menu::group_label(label, indented),
898            Entry::Item(item) => {
899                let is_highlighted = highlighted == Some(index);
900                let row = item_row(item, is_highlighted, slot, on_event);
901                let Some(children) = item.submenu().filter(|_| is_highlighted && submenu_open)
902                else {
903                    return row;
904                };
905                // The root menu dismisses everything, so a submenu lets
906                // outside presses through to it.
907                anchored(row)
908                    .content(Some(level(state, children, depth + 1, on_event, width)))
909                    .placement(
910                        Placement::new(Side::Right, Align::Start)
911                            .gap(space::XS)
912                            .offset(-(space::XS + 1.0)),
913                    )
914                    .pass_through(true)
915                    .into()
916            }
917        });
918
919    menu::surface(column(rows).width(width)).into()
920}
921
922/// Whether a menu level has checkbox or radio items, whose group labels
923/// then line up with the item labels.
924fn has_marks<Id>(entries: &[Entry<Id>]) -> bool {
925    entries.iter().any(|entry| {
926        matches!(
927            entry.as_item().map(|item| &item.kind),
928            Some(Kind::Checkbox(_) | Kind::Radio(_))
929        )
930    })
931}
932
933/// Whether every row of a menu level gets a leading slot, so labels line
934/// up: when any item has a check mark, a radio dot or an icon.
935fn reserves_slot<Id>(entries: &[Entry<Id>]) -> bool {
936    has_marks(entries)
937        || entries
938            .iter()
939            .any(|entry| entry.as_item().is_some_and(|item| item.icon.is_some()))
940}
941
942/// What fills an item's leading slot: its mark while checked, otherwise
943/// its icon, otherwise nothing.
944fn leading<Id>(item: &Item<Id>, slot: bool) -> Leading {
945    match (&item.kind, slot) {
946        (_, false) => Leading::None,
947        (Kind::Checkbox(true), true) => Leading::Check,
948        (Kind::Radio(true), true) => Leading::Dot,
949        (_, true) => Leading::Empty,
950    }
951}
952
953fn item_row<'a, Id, Message>(
954    item: &'a Item<Id>,
955    highlighted: bool,
956    slot: bool,
957    on_event: Option<&dyn Fn(Event<Id>) -> Message>,
958) -> Element<'a, Message>
959where
960    Id: Copy + 'a,
961    Message: Clone + 'a,
962{
963    let enabled = on_event.filter(|_| item.enabled());
964    let status = match (enabled.is_some(), highlighted) {
965        (false, _) => RowStatus::Disabled,
966        (true, true) => RowStatus::Highlighted,
967        (true, false) => RowStatus::Idle,
968    };
969    let leading = leading(item, slot);
970    let trailing = if item.submenu().is_some() {
971        Trailing::Chevron
972    } else {
973        Trailing::None
974    };
975    let parts = Parts {
976        icon: item.icon,
977        hint: item.shortcut.as_deref(),
978        leading,
979        trailing,
980        ..Parts::label(&item.label)
981    };
982    let messages = enabled.map(|on_event| {
983        (
984            on_event(Event::Highlight(item.id)),
985            on_event(Event::Activate(item.id)),
986        )
987    });
988    menu::item(parts, status, item.destructive, messages)
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use iced::keyboard::Modifiers;
995    use iced::widget::text;
996
997    const COPY: u8 = 1;
998    const PASTE: u8 = 2;
999    const DELETE: u8 = 3;
1000    const SHARE: u8 = 4;
1001    const EMAIL: u8 = 5;
1002    const LINK: u8 = 6;
1003    const GRID: u8 = 7;
1004    const SMALL: u8 = 8;
1005    const LARGE: u8 = 9;
1006    const PREVIEW: u8 = 10;
1007
1008    fn state() -> State<u8> {
1009        State::new([
1010            group_label("Edit"),
1011            item(COPY, "Copy").shortcut("Ctrl+C"),
1012            item(PASTE, "Paste").disabled(true),
1013            item(DELETE, "Delete").destructive(true),
1014            separator(),
1015            submenu(
1016                SHARE,
1017                "Share",
1018                [item(EMAIL, "Email"), item(LINK, "Copy link")],
1019            ),
1020            checkbox_item(GRID, "Show grid", true),
1021            separator(),
1022            radio_item(SMALL, "Small", true),
1023            radio_item(LARGE, "Large", false),
1024            item(PREVIEW, "Preview"),
1025        ])
1026    }
1027
1028    fn open() -> State<u8> {
1029        let mut state = state();
1030        let _ = state.update(Event::Open);
1031        state
1032    }
1033
1034    #[test]
1035    fn builders_set_item_fields_and_ignore_labels() {
1036        let entry: Entry<u8> = item(1, "Save")
1037            .icon(crate::lucide!(Save))
1038            .shortcut("Ctrl+S")
1039            .disabled(true)
1040            .destructive(true);
1041        let item = entry.as_item().unwrap();
1042        assert_eq!(item.label, "Save");
1043        assert_eq!(item.icon, Some(crate::lucide!(Save)));
1044        assert_eq!(item.shortcut.as_deref(), Some("Ctrl+S"));
1045        assert!(item.disabled && item.destructive);
1046        assert_eq!(item.kind, Kind::Action);
1047
1048        let label: Entry<u8> = group_label("Group")
1049            .icon(crate::lucide!(Save))
1050            .disabled(true);
1051        assert_eq!(label, Entry::Label("Group".into()));
1052        assert_eq!(separator::<u8>().shortcut("X"), Entry::Separator);
1053    }
1054
1055    #[test]
1056    fn starts_closed_and_opens_without_a_highlight() {
1057        let mut state = state();
1058        assert!(!state.is_open());
1059        assert_eq!(state.depth(), 0);
1060        assert_eq!(state.update(Event::Open), None);
1061        assert!(state.is_open());
1062        assert_eq!(state.highlighted(), None);
1063        let _ = state.update(Event::Open);
1064        assert_eq!(state.depth(), 1);
1065    }
1066
1067    #[test]
1068    fn toggle_and_close() {
1069        let mut state = state();
1070        let _ = state.update(Event::Toggle);
1071        assert!(state.is_open());
1072        let _ = state.update(Event::Toggle);
1073        assert!(!state.is_open());
1074        let _ = state.update(Event::Next);
1075        let _ = state.update(Event::Close);
1076        assert!(!state.is_open());
1077        assert_eq!(state.highlighted(), None);
1078    }
1079
1080    #[test]
1081    fn next_skips_labels_separators_and_disabled_items_and_wraps() {
1082        let mut state = open();
1083        let mut seen = Vec::new();
1084        for _ in 0..8 {
1085            let _ = state.update(Event::Next);
1086            seen.push(state.highlighted().unwrap());
1087        }
1088        assert_eq!(
1089            seen,
1090            [COPY, DELETE, SHARE, GRID, SMALL, LARGE, PREVIEW, COPY]
1091        );
1092    }
1093
1094    #[test]
1095    fn previous_wraps_backwards() {
1096        let mut state = open();
1097        let _ = state.update(Event::Previous);
1098        assert_eq!(state.highlighted(), Some(PREVIEW));
1099        let _ = state.update(Event::First);
1100        let _ = state.update(Event::Previous);
1101        assert_eq!(state.highlighted(), Some(PREVIEW));
1102    }
1103
1104    #[test]
1105    fn first_and_last() {
1106        let mut state = open();
1107        let _ = state.update(Event::Last);
1108        assert_eq!(state.highlighted(), Some(PREVIEW));
1109        let _ = state.update(Event::First);
1110        assert_eq!(state.highlighted(), Some(COPY));
1111    }
1112
1113    #[test]
1114    fn moving_opens_a_closed_menu_on_an_end() {
1115        let mut down = state();
1116        let _ = down.update(Event::Next);
1117        assert!(down.is_open());
1118        assert_eq!(down.highlighted(), Some(COPY));
1119
1120        let mut up = state();
1121        let _ = up.update(Event::Previous);
1122        assert_eq!(up.highlighted(), Some(PREVIEW));
1123
1124        let mut enter = state();
1125        assert_eq!(enter.update(Event::ActivateHighlighted), None);
1126        assert_eq!(enter.highlighted(), Some(COPY));
1127    }
1128
1129    #[test]
1130    fn empty_and_all_disabled_menus_never_highlight() {
1131        let mut empty: State<u8> = State::new([]);
1132        let _ = empty.update(Event::Next);
1133        assert!(empty.is_open());
1134        assert_eq!(empty.highlighted(), None);
1135        assert_eq!(empty.update(Event::ActivateHighlighted), None);
1136
1137        let mut disabled = State::new([item(1u8, "A").disabled(true), separator()]);
1138        let _ = disabled.update(Event::Next);
1139        let _ = disabled.update(Event::Last);
1140        assert_eq!(disabled.highlighted(), None);
1141        assert_eq!(disabled.update(Event::Activate(1)), None);
1142    }
1143
1144    #[test]
1145    fn activating_an_action_returns_it_and_closes() {
1146        let mut state = open();
1147        assert_eq!(
1148            state.update(Event::Activate(COPY)),
1149            Some(Output::Activated(COPY))
1150        );
1151        assert!(!state.is_open());
1152    }
1153
1154    #[test]
1155    fn disabled_and_unknown_items_do_nothing() {
1156        let mut state = open();
1157        assert_eq!(state.update(Event::Activate(PASTE)), None);
1158        assert_eq!(state.update(Event::Activate(99)), None);
1159        let _ = state.update(Event::Highlight(PASTE));
1160        assert_eq!(state.highlighted(), None);
1161        assert!(state.is_open());
1162    }
1163
1164    #[test]
1165    fn enter_activates_the_highlighted_item() {
1166        let mut state = open();
1167        let _ = state.update(Event::Next);
1168        let _ = state.update(Event::Next);
1169        assert_eq!(
1170            state.update(Event::ActivateHighlighted),
1171            Some(Output::Activated(DELETE))
1172        );
1173        let mut nothing = open();
1174        assert_eq!(nothing.update(Event::ActivateHighlighted), None);
1175        assert!(nothing.is_open());
1176    }
1177
1178    #[test]
1179    fn checkbox_items_toggle() {
1180        let mut state = open();
1181        assert!(state.is_checked(GRID));
1182        assert_eq!(
1183            state.update(Event::Activate(GRID)),
1184            Some(Output::Toggled(GRID, false))
1185        );
1186        assert!(!state.is_checked(GRID));
1187        let _ = state.update(Event::Open);
1188        assert_eq!(
1189            state.update(Event::Activate(GRID)),
1190            Some(Output::Toggled(GRID, true))
1191        );
1192    }
1193
1194    #[test]
1195    fn radio_items_check_one_of_their_run() {
1196        let mut state = open();
1197        assert_eq!(
1198            state.update(Event::Activate(LARGE)),
1199            Some(Output::Selected(LARGE))
1200        );
1201        assert!(state.is_checked(LARGE));
1202        assert!(!state.is_checked(SMALL));
1203        state.set_checked(SMALL, true);
1204        assert!(state.is_checked(SMALL));
1205        assert!(!state.is_checked(LARGE));
1206        state.set_checked(SMALL, false);
1207        assert!(!state.is_checked(SMALL));
1208    }
1209
1210    #[test]
1211    fn radio_group_is_the_adjacent_run() {
1212        let entries: Vec<Entry<u8>> = vec![
1213            radio_item(1, "A", false),
1214            separator(),
1215            radio_item(2, "B", false),
1216            radio_item(3, "C", false),
1217            item(4, "D"),
1218            radio_item(5, "E", false),
1219        ];
1220        assert_eq!(radio_group(&entries, 0), 0..1);
1221        assert_eq!(radio_group(&entries, 2), 2..4);
1222        assert_eq!(radio_group(&entries, 3), 2..4);
1223        assert_eq!(radio_group(&entries, 5), 5..6);
1224    }
1225
1226    #[test]
1227    fn set_checked_ignores_actions_and_unknown_ids() {
1228        let mut state = state();
1229        state.set_checked(COPY, true);
1230        state.set_checked(99, true);
1231        assert!(!state.is_checked(COPY));
1232        assert_eq!(state, self::state());
1233    }
1234
1235    #[test]
1236    fn set_disabled_drops_the_highlight() {
1237        let mut state = open();
1238        let _ = state.update(Event::Highlight(DELETE));
1239        state.set_disabled(DELETE, true);
1240        assert_eq!(state.highlighted(), None);
1241        assert!(state.item(DELETE).unwrap().disabled);
1242        state.set_disabled(PASTE, false);
1243        let _ = state.update(Event::First);
1244        let _ = state.update(Event::Next);
1245        assert_eq!(state.highlighted(), Some(PASTE));
1246    }
1247
1248    #[test]
1249    fn disabling_an_item_in_another_submenu_keeps_the_active_highlight() {
1250        let mut state = State::new([
1251            submenu(1, "Share", [item(2, "Email")]),
1252            submenu(3, "Export", [item(4, "PDF")]),
1253        ]);
1254        let _ = state.update(Event::Open);
1255        let _ = state.update(Event::Highlight(2));
1256
1257        state.set_disabled(4, true);
1258
1259        assert_eq!(state.highlighted(), Some(2));
1260        assert_eq!(
1261            state.update(Event::ActivateHighlighted),
1262            Some(Output::Activated(2))
1263        );
1264    }
1265
1266    #[test]
1267    fn pointer_on_a_submenu_opens_it_without_a_highlight() {
1268        let mut state = open();
1269        let _ = state.update(Event::Highlight(SHARE));
1270        assert_eq!(state.depth(), 2);
1271        assert_eq!(state.highlighted(), Some(SHARE));
1272        assert_eq!(state.highlighted_at(1), None);
1273
1274        let _ = state.update(Event::Highlight(LINK));
1275        assert_eq!(state.highlighted(), Some(LINK));
1276        let _ = state.update(Event::Highlight(SHARE));
1277        assert_eq!(
1278            state.highlighted(),
1279            Some(LINK),
1280            "returning to the parent keeps the submenu"
1281        );
1282
1283        let _ = state.update(Event::Highlight(COPY));
1284        assert_eq!(state.depth(), 1, "another item closes the submenu");
1285    }
1286
1287    #[test]
1288    fn keyboard_opens_and_closes_submenus() {
1289        let mut state = open();
1290        let _ = state.update(Event::Highlight(SHARE));
1291        let _ = state.update(Event::CloseSubmenu);
1292        assert_eq!(state.depth(), 1);
1293        assert_eq!(state.highlighted(), Some(SHARE));
1294
1295        let _ = state.update(Event::OpenSubmenu);
1296        assert_eq!(state.highlighted(), Some(EMAIL));
1297        let _ = state.update(Event::Next);
1298        assert_eq!(state.highlighted(), Some(LINK));
1299        let _ = state.update(Event::Next);
1300        assert_eq!(state.highlighted(), Some(EMAIL), "wraps inside the submenu");
1301
1302        let _ = state.update(Event::CloseSubmenu);
1303        assert_eq!(state.depth(), 1);
1304        assert_eq!(state.highlighted(), Some(SHARE));
1305        let _ = state.update(Event::CloseSubmenu);
1306        assert!(state.is_open(), "the root menu stays open");
1307    }
1308
1309    #[test]
1310    fn enter_on_a_submenu_opens_it_and_items_inside_activate() {
1311        let mut state = open();
1312        let _ = state.update(Event::Highlight(SHARE));
1313        let _ = state.update(Event::CloseSubmenu);
1314        assert_eq!(state.update(Event::ActivateHighlighted), None);
1315        assert_eq!(state.highlighted(), Some(EMAIL));
1316        assert_eq!(
1317            state.update(Event::ActivateHighlighted),
1318            Some(Output::Activated(EMAIL))
1319        );
1320        assert!(!state.is_open());
1321    }
1322
1323    #[test]
1324    fn clicking_a_submenu_item_opens_it() {
1325        let mut state = open();
1326        assert_eq!(state.update(Event::Activate(SHARE)), None);
1327        assert_eq!(state.depth(), 2);
1328        assert_eq!(
1329            state.update(Event::Activate(LINK)),
1330            Some(Output::Activated(LINK))
1331        );
1332    }
1333
1334    #[test]
1335    fn open_submenu_ignores_plain_items() {
1336        let mut state = open();
1337        let _ = state.update(Event::First);
1338        let _ = state.update(Event::OpenSubmenu);
1339        assert_eq!(state.depth(), 1);
1340    }
1341
1342    #[test]
1343    fn moving_in_the_parent_closes_the_submenu() {
1344        let mut state = open();
1345        let _ = state.update(Event::Highlight(SHARE));
1346        let _ = state.update(Event::Next);
1347        assert_eq!(state.depth(), 1);
1348        assert_eq!(state.highlighted(), Some(GRID));
1349    }
1350
1351    #[test]
1352    fn typeahead_jumps_to_matching_labels_and_cycles() {
1353        let mut state = open();
1354        let _ = state.update(Event::Typeahead('s'));
1355        assert_eq!(state.highlighted(), Some(SHARE));
1356        let _ = state.update(Event::Typeahead('S'));
1357        assert_eq!(state.highlighted(), Some(GRID));
1358        let _ = state.update(Event::Typeahead('s'));
1359        assert_eq!(state.highlighted(), Some(SMALL));
1360        let _ = state.update(Event::Typeahead('s'));
1361        assert_eq!(state.highlighted(), Some(SHARE));
1362        let _ = state.update(Event::Typeahead('p'));
1363        assert_eq!(
1364            state.highlighted(),
1365            Some(PREVIEW),
1366            "skips the disabled Paste"
1367        );
1368        let _ = state.update(Event::Typeahead('z'));
1369        assert_eq!(state.highlighted(), Some(PREVIEW));
1370    }
1371
1372    #[test]
1373    fn typeahead_starts_at_the_first_match() {
1374        let mut state = open();
1375        let _ = state.update(Event::Typeahead('c'));
1376        assert_eq!(state.highlighted(), Some(COPY));
1377    }
1378
1379    fn key(key: Key, modifiers: Modifiers) -> keys::Event {
1380        keys::Event { key, modifiers }
1381    }
1382
1383    fn character(c: &str) -> Key {
1384        Key::Character(c.into())
1385    }
1386
1387    #[test]
1388    fn typeahead_accepts_plain_letters_and_digits_only() {
1389        let none = Modifiers::empty();
1390        assert_eq!(typeahead(&key(character("a"), none)), Some('a'));
1391        assert_eq!(typeahead(&key(character("A"), Modifiers::SHIFT)), Some('A'));
1392        assert_eq!(typeahead(&key(character("7"), none)), Some('7'));
1393        assert_eq!(typeahead(&key(character("a"), Modifiers::CTRL)), None);
1394        assert_eq!(typeahead(&key(character("a"), Modifiers::ALT)), None);
1395        assert_eq!(typeahead(&key(character(" "), none)), None);
1396        assert_eq!(typeahead(&key(character("/"), none)), None);
1397        assert_eq!(typeahead(&key(character("ab"), none)), None);
1398        assert_eq!(typeahead(&key(Key::Named(Named::Enter), none)), None);
1399    }
1400
1401    #[test]
1402    fn key_event_uses_the_keymap_then_typeahead() {
1403        let keymap = default_keymap();
1404        let closed = state();
1405        let down = key(Key::Named(Named::ArrowDown), Modifiers::empty());
1406        let enter = key(Key::Named(Named::Enter), Modifiers::empty());
1407        let letter = key(character("d"), Modifiers::empty());
1408        assert_eq!(
1409            closed.key_event(&keymap, &down),
1410            None,
1411            "closed menus claim no keys"
1412        );
1413        assert_eq!(closed.key_event(&keymap, &enter), None);
1414        assert_eq!(closed.key_event(&keymap, &letter), None);
1415
1416        let bound = default_keymap().bind(Chord::named(Named::F2), Action::Open);
1417        let f2 = key(Key::Named(Named::F2), Modifiers::empty());
1418        assert_eq!(closed.key_event(&bound, &f2), Some(Event::First));
1419
1420        let open = open();
1421        assert_eq!(
1422            open.key_event(&keymap, &letter),
1423            Some(Event::Typeahead('d'))
1424        );
1425        let escape = key(Key::Named(Named::Escape), Modifiers::empty());
1426        assert_eq!(open.key_event(&keymap, &escape), Some(Event::Close));
1427    }
1428
1429    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1430        let chord: Chord = chord.parse().unwrap();
1431        keymap.resolve(chord.key(), chord.modifiers())
1432    }
1433
1434    #[test]
1435    fn default_keymap_covers_every_action() {
1436        let keymap = default_keymap();
1437        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1438        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1439        assert_eq!(press(&keymap, "Home"), Some(Action::First));
1440        assert_eq!(press(&keymap, "End"), Some(Action::Last));
1441        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1442        assert_eq!(press(&keymap, "Space"), Some(Action::Activate));
1443        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1444        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::OpenSubmenu));
1445        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::CloseSubmenu));
1446        assert_eq!(press(&keymap, "Tab"), None);
1447        for action in <Action as keys::Action>::ALL {
1448            let bound = !keymap.chords(action).is_empty();
1449            assert_eq!(bound, *action != Action::Open, "{action:?}");
1450        }
1451        assert_eq!(Keymap::<Action>::defaults(), keymap);
1452    }
1453
1454    #[test]
1455    fn keymap_overrides_and_unbinding() {
1456        let keymap = default_keymap()
1457            .unbind(&"Space".parse().unwrap())
1458            .bind("J".parse().unwrap(), Action::Next);
1459        assert_eq!(press(&keymap, "Space"), None);
1460        assert_eq!(press(&keymap, "j"), Some(Action::Next));
1461        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1462    }
1463
1464    #[test]
1465    fn a_closed_menu_only_answers_open() {
1466        let closed = state();
1467        assert_eq!(Action::Open.event(&closed), Some(Event::First));
1468        for &action in <Action as keys::Action>::ALL {
1469            if action != Action::Open {
1470                assert_eq!(action.event(&closed), None, "{action:?}");
1471            }
1472        }
1473    }
1474
1475    #[test]
1476    fn an_open_menu_maps_every_action_but_open() {
1477        let open = open();
1478        assert_eq!(Action::Open.event(&open), None);
1479        assert_eq!(Action::Next.event(&open), Some(Event::Next));
1480        assert_eq!(Action::Previous.event(&open), Some(Event::Previous));
1481        assert_eq!(Action::First.event(&open), Some(Event::First));
1482        assert_eq!(Action::Last.event(&open), Some(Event::Last));
1483        assert_eq!(
1484            Action::Activate.event(&open),
1485            Some(Event::ActivateHighlighted)
1486        );
1487        assert_eq!(Action::Close.event(&open), Some(Event::Close));
1488        assert_eq!(Action::OpenSubmenu.event(&open), Some(Event::OpenSubmenu));
1489        assert_eq!(Action::CloseSubmenu.event(&open), Some(Event::CloseSubmenu));
1490    }
1491
1492    #[test]
1493    fn close_in_a_submenu_closes_only_the_submenu() {
1494        let mut state = open();
1495        let _ = state.update(Event::Highlight(SHARE));
1496        assert_eq!(Action::Close.event(&state), Some(Event::CloseSubmenu));
1497        let _ = state.update(Event::CloseSubmenu);
1498        assert_eq!(Action::Close.event(&state), Some(Event::Close));
1499    }
1500
1501    #[test]
1502    fn every_action_has_a_name_and_a_sentence() {
1503        for &action in <Action as keys::Action>::ALL {
1504            assert!(!keys::Action::name(action).is_empty());
1505            assert!(keys::Action::description(action).ends_with('.'));
1506        }
1507    }
1508
1509    #[test]
1510    fn builder_defaults() {
1511        let state = state();
1512        let menu: DropdownMenu<'_, u8, ()> = dropdown_menu(&state, text("Open"));
1513        assert_eq!(menu.placement, Placement::new(Side::Bottom, Align::Start));
1514        assert_eq!(menu.width, WIDTH);
1515        assert_eq!(menu.keymap, default_keymap());
1516        assert!(menu.on_event.is_none());
1517
1518        let menu: DropdownMenu<'_, u8, ()> = dropdown_menu(&state, text("Open"))
1519            .side(Side::Top)
1520            .align(Align::End)
1521            .gap(8.0)
1522            .width(300.0)
1523            .keymap(Keymap::new())
1524            .on_event(|_| ());
1525        assert_eq!(menu.placement.side, Side::Top);
1526        assert_eq!(menu.placement.align, Align::End);
1527        assert_eq!(menu.placement.gap, 8.0);
1528        assert_eq!(menu.width, 300.0);
1529        assert!(menu.keymap.is_empty());
1530        assert!(menu.on_event.is_some());
1531    }
1532
1533    #[test]
1534    fn a_slot_is_reserved_for_marks_or_icons() {
1535        let plain: Vec<Entry<u8>> = vec![item(COPY, "Copy"), separator()];
1536        assert!(!reserves_slot(&plain) && !has_marks(&plain));
1537
1538        let icons: Vec<Entry<u8>> = vec![
1539            item(COPY, "Copy").icon(crate::lucide!(Copy)),
1540            item(PASTE, "Paste"),
1541        ];
1542        assert!(reserves_slot(&icons) && !has_marks(&icons));
1543
1544        let marks: Vec<Entry<u8>> = vec![item(COPY, "Copy"), radio_item(SMALL, "Small", false)];
1545        assert!(reserves_slot(&marks) && has_marks(&marks));
1546    }
1547
1548    #[test]
1549    fn the_slot_holds_the_mark_while_checked_and_the_icon_otherwise() {
1550        let entries: Vec<Entry<u8>> = vec![
1551            checkbox_item(GRID, "Grid", true).icon(crate::lucide!(Grid3x3)),
1552            checkbox_item(GRID, "Grid", false).icon(crate::lucide!(Grid3x3)),
1553            radio_item(SMALL, "Small", true),
1554            radio_item(LARGE, "Large", false),
1555            item(COPY, "Copy").icon(crate::lucide!(Copy)),
1556        ];
1557        let slots: Vec<Leading> = entries
1558            .iter()
1559            .filter_map(Entry::as_item)
1560            .map(|item| leading(item, true))
1561            .collect();
1562        assert_eq!(
1563            slots,
1564            [
1565                Leading::Check,
1566                Leading::Empty,
1567                Leading::Dot,
1568                Leading::Empty,
1569                Leading::Empty
1570            ]
1571        );
1572        let unslotted = entries.iter().filter_map(Entry::as_item);
1573        assert!(
1574            unslotted
1575                .map(|item| leading(item, false))
1576                .all(|l| l == Leading::None)
1577        );
1578    }
1579}