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