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