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