1use 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
29pub const WIDTH: f32 = 224.0;
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum Kind<Id> {
35 Action,
37 Checkbox(bool),
39 Radio(bool),
42 Submenu(Vec<Entry<Id>>),
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct Item<Id> {
49 pub id: Id,
50 pub label: String,
51 pub icon: Option<Glyph>,
52 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#[derive(Debug, Clone, PartialEq)]
74pub enum Entry<Id> {
75 Item(Item<Id>),
76 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
93pub fn item<Id>(id: Id, label: impl Into<String>) -> Entry<Id> {
95 new_item(id, label, Kind::Action)
96}
97
98pub fn checkbox_item<Id>(id: Id, label: impl Into<String>, checked: bool) -> Entry<Id> {
100 new_item(id, label, Kind::Checkbox(checked))
101}
102
103pub fn radio_item<Id>(id: Id, label: impl Into<String>, checked: bool) -> Entry<Id> {
105 new_item(id, label, Kind::Radio(checked))
106}
107
108pub 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
117pub fn group_label<Id>(label: impl Into<String>) -> Entry<Id> {
119 Entry::Label(label.into())
120}
121
122pub fn separator<Id>() -> Entry<Id> {
124 Entry::Separator
125}
126
127impl<Id> Entry<Id> {
128 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Event<Id> {
170 Open,
172 Close,
173 Toggle,
174 Highlight(Id),
176 Next,
179 Previous,
182 First,
183 Last,
184 OpenSubmenu,
186 CloseSubmenu,
188 Activate(Id),
190 ActivateHighlighted,
193 Typeahead(char),
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Output<Id> {
200 Activated(Id),
202 Toggled(Id, bool),
204 Selected(Id),
206}
207
208#[derive(Debug, Clone, PartialEq)]
210pub struct State<Id> {
211 entries: Vec<Entry<Id>>,
212 levels: Vec<Option<usize>>,
214}
215
216impl<Id: Copy + PartialEq> State<Id> {
217 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 pub fn depth(&self) -> usize {
236 self.levels.len()
237 }
238
239 pub fn highlighted(&self) -> Option<Id> {
241 let path = self.active_path()?;
242 self.item_at(&path).map(|item| item.id)
243 }
244
245 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 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 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 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 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 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 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 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 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
544fn 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
568fn 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
597fn 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
619pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
639pub enum Action {
640 Open,
642 Next,
643 Previous,
644 First,
645 Last,
646 Activate,
647 Close,
648 OpenSubmenu,
649 CloseSubmenu,
650}
651
652impl Action {
653 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
722pub 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
754pub 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
775pub 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 pub fn side(mut self, side: Side) -> Self {
797 self.placement.side = side;
798 self
799 }
800
801 pub fn align(mut self, align: Align) -> Self {
803 self.placement.align = align;
804 self
805 }
806
807 pub fn gap(mut self, gap: f32) -> Self {
809 self.placement.gap = gap;
810 self
811 }
812
813 pub fn width(mut self, width: f32) -> Self {
815 self.width = width;
816 self
817 }
818
819 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
864pub(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 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 let rows = column(rows).width(width);
926 menu::surface(scrolling(rows, highlighted)).into()
927}
928
929fn 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
940fn 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
949fn 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}