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> {
525 Keymap::new()
526 .bind(Chord::named(Named::F10), Action::Focus)
527 .bind(Chord::named(Named::Alt), Action::Focus)
528 .bind(Chord::named(Named::ArrowRight), Action::NextMenu)
529 .bind(Chord::named(Named::ArrowLeft), Action::PreviousMenu)
530 .bind(Chord::named(Named::ArrowDown), Action::Open)
531 .bind(Chord::named(Named::Enter), Action::Open)
532 .bind(Chord::named(Named::Space), Action::Open)
533 .bind(Chord::named(Named::Escape), Action::Close)
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
538pub enum TriggerStatus {
539 #[default]
540 Idle,
541 Hovered,
542 Active,
544 Disabled,
545}
546
547impl TriggerStatus {
548 pub const ALL: [TriggerStatus; 4] = [
549 TriggerStatus::Idle,
550 TriggerStatus::Hovered,
551 TriggerStatus::Active,
552 TriggerStatus::Disabled,
553 ];
554}
555
556pub fn trigger_style(tokens: &Tokens, status: TriggerStatus) -> widget::button::Style {
559 let (background, text_color) = match status {
560 TriggerStatus::Idle => (None, tokens.foreground),
561 TriggerStatus::Hovered | TriggerStatus::Active => {
562 (Some(tokens.accent), tokens.accent_foreground)
563 }
564 TriggerStatus::Disabled => (None, tokens.muted_foreground),
565 };
566 widget::button::Style {
567 background: background.map(Background::Color),
568 text_color,
569 border: Border {
570 color: Color::TRANSPARENT,
571 width: 0.0,
572 radius: radius::SM.into(),
573 },
574 shadow: Shadow::default(),
575 snap: true,
576 }
577}
578
579pub fn bar_style(tokens: &Tokens) -> container::Style {
581 container::Style {
582 background: Some(Background::Color(tokens.background)),
583 text_color: Some(tokens.foreground),
584 ..container::Style::default()
585 }
586}
587
588fn trigger_status(status: widget::button::Status, active: bool) -> TriggerStatus {
589 match status {
590 widget::button::Status::Disabled => TriggerStatus::Disabled,
591 _ if active => TriggerStatus::Active,
592 widget::button::Status::Hovered | widget::button::Status::Pressed => TriggerStatus::Hovered,
593 widget::button::Status::Active => TriggerStatus::Idle,
594 }
595}
596
597type OnEvent<'a, Id, Message> = Rc<dyn Fn(Event<Id>) -> Message + 'a>;
598
599pub struct Menubar<'a, Id, Message> {
601 state: &'a State<Id>,
602 width: f32,
603 keymap: Keymap<Action>,
604 menu_keymap: Keymap<dropdown_menu::Action>,
605 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
606}
607
608impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Menubar<'_, Id, Message> {
609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610 f.debug_struct("Menubar")
611 .field("state", self.state)
612 .field("width", &self.width)
613 .field("keymap", &self.keymap)
614 .field("menu_keymap", &self.menu_keymap)
615 .finish_non_exhaustive()
616 }
617}
618
619pub fn menubar<'a, Id, Message>(state: &'a State<Id>) -> Menubar<'a, Id, Message> {
622 Menubar {
623 state,
624 width: dropdown_menu::WIDTH,
625 keymap: default_keymap(),
626 menu_keymap: dropdown_menu::default_keymap(),
627 on_event: None,
628 }
629}
630
631impl<'a, Id, Message> Menubar<'a, Id, Message> {
632 pub fn width(mut self, width: f32) -> Self {
634 self.width = width;
635 self
636 }
637
638 pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
640 self.keymap = keymap;
641 self
642 }
643
644 pub fn menu_keymap(mut self, keymap: Keymap<dropdown_menu::Action>) -> Self {
647 self.menu_keymap = keymap;
648 self
649 }
650
651 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
652 self.on_event = Some(Box::new(on_event));
653 self
654 }
655}
656
657impl<'a, Id, Message> From<Menubar<'a, Id, Message>> for Element<'a, Message>
658where
659 Id: Copy + PartialEq + 'a,
660 Message: Clone + 'a,
661{
662 fn from(menubar: Menubar<'a, Id, Message>) -> Self {
663 let Menubar {
664 state,
665 width,
666 keymap,
667 menu_keymap,
668 on_event,
669 } = menubar;
670 let on_event: Option<OnEvent<'a, Id, Message>> = on_event.map(Rc::from);
671 let keymaps = Rc::new((keymap, menu_keymap));
672
673 let titles = state.menus.iter().enumerate().map(|(index, menu)| {
674 title(state, index, menu, on_event.clone(), keymaps.clone(), width)
675 });
676
677 column![
678 container(row(titles).spacing(2))
679 .padding([space::XS, space::XS])
680 .width(Length::Fill)
681 .style(|theme| bar_style(&Tokens::of(theme))),
682 crate::application::edge(),
683 ]
684 .width(Length::Fill)
685 .into()
686 }
687}
688
689fn title<'a, Id, Message>(
691 state: &'a State<Id>,
692 index: usize,
693 menu: &'a Menu<Id>,
694 on_event: Option<OnEvent<'a, Id, Message>>,
695 keymaps: Rc<(Keymap<Action>, Keymap<dropdown_menu::Action>)>,
696 width: f32,
697) -> Element<'a, Message>
698where
699 Id: Copy + PartialEq + 'a,
700 Message: Clone + 'a,
701{
702 let open = menu.state.is_open();
703 let active = open || (!state.is_open() && state.focused == Some(index));
704 let label = title_label(menu, state.shows_mnemonics());
705 let trigger = widget::button(label)
706 .padding(TRIGGER_PADDING)
707 .style(move |theme, status| {
708 trigger_style(&Tokens::of(theme), trigger_status(status, active))
709 })
710 .on_press_maybe(
711 on_event
712 .as_ref()
713 .map(|on_event| on_event(Event::Toggle(index))),
714 );
715
716 let mut anchor = mouse_area(trigger);
717 if let Some(on_event) = on_event.as_ref().filter(|_| state.is_open() && !open) {
718 anchor = anchor.on_enter(on_event(Event::Hover(index)));
719 }
720
721 let on_menu = on_event
722 .clone()
723 .map(|on_event| move |event| on_event(Event::Menu(index, event)));
724 let panel = open.then(|| {
725 dropdown_menu::panel(
726 &menu.state,
727 on_menu
728 .as_ref()
729 .map(|f| f as &dyn Fn(dropdown_menu::Event<Id>) -> Message),
730 width,
731 )
732 });
733 let floating = anchored(anchor)
734 .content(panel)
735 .placement(Placement::new(Side::Bottom, Align::Start))
736 .dismiss_keys([]);
737 let Some(on_event) = on_event.filter(|_| open) else {
738 return floating.into();
739 };
740 floating
741 .on_dismiss(on_event(Event::Dismiss))
742 .on_key(move |key| {
743 let (keymap, menu_keymap) = &*keymaps;
744 state.key_event(keymap, menu_keymap, key).map(&*on_event)
745 })
746 .into()
747}
748
749fn title_label<'a, Message: 'a, Id>(menu: &Menu<Id>, underline: bool) -> Element<'a, Message> {
751 let plain = || {
752 text(menu.title.clone())
753 .size(text_size::SM)
754 .line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
755 .wrapping(text::Wrapping::None)
756 .into()
757 };
758 if !underline {
759 return plain();
760 }
761 let Some((before, letter, after)) = split_mnemonic(&menu.title, menu.key()) else {
762 return plain();
763 };
764 let spans: Vec<Span<'a, ()>> = vec![span(before), span(letter).underline(true), span(after)];
765 rich_text(spans)
766 .size(text_size::SM)
767 .line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
768 .wrapping(text::Wrapping::None)
769 .into()
770}
771
772fn split_mnemonic(title: &str, key: Option<char>) -> Option<(String, String, String)> {
774 let key = key?;
775 let (start, letter) = title
776 .char_indices()
777 .find(|(_, c)| c.to_lowercase().next() == Some(key))?;
778 let end = start + letter.len_utf8();
779 Some((
780 title.get(..start)?.to_owned(),
781 title.get(start..end)?.to_owned(),
782 title.get(end..)?.to_owned(),
783 ))
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::overlay::dropdown_menu::{checkbox_item, item, separator, submenu};
790 use crate::theme::{dark, light};
791
792 const NEW: u8 = 1;
793 const QUIT: u8 = 2;
794 const UNDO: u8 = 3;
795 const RECENT: u8 = 4;
796 const LAST: u8 = 5;
797 const GRID: u8 = 6;
798 const ABOUT: u8 = 7;
799
800 fn state() -> State<u8> {
801 State::new([
802 menu(
803 "File",
804 [
805 item(NEW, "New").shortcut("Ctrl+N"),
806 submenu(RECENT, "Open recent", [item(LAST, "notes.md")]),
807 separator(),
808 item(QUIT, "Quit"),
809 ],
810 ),
811 menu("Edit", [item(UNDO, "Undo")]),
812 menu("View", [checkbox_item(GRID, "Show grid", true)]),
813 menu("Help", [item(ABOUT, "About")]).mnemonic('p'),
814 ])
815 }
816
817 fn key(key: Key, modifiers: Modifiers) -> keys::Event {
818 keys::Event { key, modifiers }
819 }
820
821 fn named(named: Named) -> keys::Event {
822 key(Key::Named(named), Modifiers::empty())
823 }
824
825 fn resolve_key(state: &State<u8>, event: &keys::Event) -> Option<Event<u8>> {
826 state.key_event(&default_keymap(), &dropdown_menu::default_keymap(), event)
827 }
828
829 #[test]
830 fn starts_closed_and_unfocused() {
831 let state = state();
832 assert_eq!(state.open(), None);
833 assert!(!state.is_open() && !state.is_focused());
834 assert_eq!(state.menus().len(), 4);
835 assert_eq!(state.menus()[0].title(), "File");
836 }
837
838 #[test]
839 fn mnemonics_default_to_the_first_letter() {
840 let state = state();
841 let keys: Vec<_> = state.menus().iter().map(Menu::key).collect();
842 assert_eq!(keys, [Some('f'), Some('e'), Some('v'), Some('p')]);
843 let empty: Menu<u8> = menu("", []);
844 assert_eq!(empty.key(), None);
845 }
846
847 #[test]
848 fn toggle_opens_and_closes_a_menu() {
849 let mut state = state();
850 let _ = state.update(Event::Toggle(1));
851 assert_eq!(state.open(), Some(1));
852 assert_eq!(state.focused(), Some(1));
853 assert_eq!(
854 state.highlighted(),
855 None,
856 "the pointer opens without a highlight"
857 );
858 let _ = state.update(Event::Toggle(1));
859 assert!(!state.is_open() && !state.is_focused());
860 }
861
862 #[test]
863 fn toggling_another_title_switches_menus() {
864 let mut state = state();
865 let _ = state.update(Event::Toggle(0));
866 let _ = state.update(Event::Toggle(2));
867 assert_eq!(state.open(), Some(2));
868 assert!(!state.menus()[0].state().is_open());
869 }
870
871 #[test]
872 fn hovering_switches_only_while_a_menu_is_open() {
873 let mut state = state();
874 let _ = state.update(Event::Hover(1));
875 assert!(!state.is_open(), "hover alone opens nothing");
876
877 let _ = state.update(Event::Toggle(0));
878 let _ = state.update(Event::Hover(1));
879 assert_eq!(state.open(), Some(1));
880 let _ = state.update(Event::Hover(1));
881 assert_eq!(state.open(), Some(1));
882 let _ = state.update(Event::Hover(9));
883 assert_eq!(state.open(), Some(1), "unknown titles are ignored");
884 }
885
886 #[test]
887 fn choosing_an_item_returns_it_and_leaves_the_bar() {
888 let mut state = state();
889 let _ = state.update(Event::Toggle(0));
890 assert_eq!(
891 state.update(Event::Menu(0, dropdown_menu::Event::Activate(NEW))),
892 Some(Output::Activated(NEW))
893 );
894 assert!(!state.is_open() && !state.is_focused());
895
896 let _ = state.update(Event::Toggle(2));
897 assert_eq!(
898 state.update(Event::Menu(2, dropdown_menu::Event::Activate(GRID))),
899 Some(Output::Toggled(GRID, false))
900 );
901 assert!(!state.is_checked(GRID));
902 }
903
904 #[test]
905 fn menu_events_for_unknown_menus_do_nothing() {
906 let mut state = state();
907 assert_eq!(
908 state.update(Event::Menu(9, dropdown_menu::Event::Open)),
909 None
910 );
911 assert!(!state.is_open());
912 }
913
914 #[test]
915 fn a_menu_opened_through_its_own_events_closes_the_others() {
916 let mut state = state();
917 let _ = state.update(Event::Toggle(0));
918 let _ = state.update(Event::Menu(1, dropdown_menu::Event::Next));
919 assert_eq!(state.open(), Some(1));
920 assert_eq!(state.focused(), Some(1));
921 assert!(!state.menus()[0].state().is_open());
922 }
923
924 #[test]
925 fn focus_toggles_the_bar() {
926 let mut state = state();
927 let _ = state.update(Event::Focus);
928 assert_eq!(state.focused(), Some(0));
929 assert!(!state.is_open());
930 let _ = state.update(Event::Focus);
931 assert!(!state.is_focused());
932
933 let mut empty: State<u8> = State::new([]);
934 let _ = empty.update(Event::Focus);
935 assert!(!empty.is_focused());
936 let _ = empty.update(Event::Next);
937 assert!(!empty.is_focused());
938 }
939
940 #[test]
941 fn mnemonics_show_only_while_the_keyboard_drives_the_bar() {
942 let mut state = state();
943 assert!(!state.shows_mnemonics());
944 let _ = state.update(Event::Focus);
945 assert!(state.shows_mnemonics());
946 let _ = state.update(Event::Open);
947 assert!(state.shows_mnemonics());
948 let _ = state.update(Event::Hover(2));
949 assert!(!state.shows_mnemonics(), "the pointer takes over");
950 let _ = state.update(Event::Dismiss);
951 let _ = state.update(Event::Toggle(0));
952 assert!(!state.shows_mnemonics());
953 }
954
955 #[test]
956 fn next_and_previous_move_the_focus_and_wrap() {
957 let mut state = state();
958 let _ = state.update(Event::Next);
959 assert!(!state.is_focused(), "an unfocused bar does not move");
960 let _ = state.update(Event::Focus);
961 let _ = state.update(Event::Previous);
962 assert_eq!(state.focused(), Some(3));
963 let _ = state.update(Event::Next);
964 assert_eq!(state.focused(), Some(0));
965 assert!(!state.is_open());
966 }
967
968 #[test]
969 fn next_with_a_menu_open_opens_the_next_menu_on_its_first_item() {
970 let mut state = state();
971 let _ = state.update(Event::Toggle(0));
972 let _ = state.update(Event::Next);
973 assert_eq!(state.open(), Some(1));
974 assert_eq!(state.highlighted(), Some(UNDO));
975 let _ = state.update(Event::Previous);
976 let _ = state.update(Event::Previous);
977 assert_eq!(state.open(), Some(3));
978 assert_eq!(state.highlighted(), Some(ABOUT));
979 }
980
981 #[test]
982 fn open_opens_the_focused_menu_on_its_first_item() {
983 let mut state = state();
984 let _ = state.update(Event::Open);
985 assert!(!state.is_open(), "nothing is focused");
986 let _ = state.update(Event::Focus);
987 let _ = state.update(Event::Next);
988 let _ = state.update(Event::Open);
989 assert_eq!(state.open(), Some(1));
990 assert_eq!(state.highlighted(), Some(UNDO));
991 }
992
993 #[test]
994 fn open_menu_opens_by_position() {
995 let mut state = state();
996 let _ = state.update(Event::OpenMenu(2));
997 assert_eq!(state.open(), Some(2));
998 assert_eq!(state.highlighted(), Some(GRID));
999 let _ = state.update(Event::OpenMenu(7));
1000 assert_eq!(state.open(), Some(2));
1001 }
1002
1003 #[test]
1004 fn close_returns_to_the_title_then_leaves() {
1005 let mut state = state();
1006 let _ = state.update(Event::OpenMenu(1));
1007 let _ = state.update(Event::Close);
1008 assert!(!state.is_open());
1009 assert_eq!(state.focused(), Some(1));
1010 let _ = state.update(Event::Close);
1011 assert!(!state.is_focused());
1012
1013 let _ = state.update(Event::OpenMenu(1));
1014 let _ = state.update(Event::Menu(1, dropdown_menu::Event::Close));
1015 assert_eq!(state.focused(), Some(1), "a menu's own Close acts the same");
1016 }
1017
1018 #[test]
1019 fn dismiss_closes_everything() {
1020 let mut state = state();
1021 let _ = state.update(Event::OpenMenu(1));
1022 let _ = state.update(Event::Dismiss);
1023 assert!(!state.is_open() && !state.is_focused());
1024 }
1025
1026 #[test]
1027 fn checked_and_disabled_items_are_found_in_any_menu() {
1028 let mut state = state();
1029 assert!(state.is_checked(GRID));
1030 state.set_checked(GRID, false);
1031 assert!(!state.is_checked(GRID));
1032 state.set_disabled(UNDO, true);
1033 assert!(
1034 state.menus()[1]
1035 .state()
1036 .item(UNDO)
1037 .is_some_and(|item| item.disabled)
1038 );
1039 let _ = state.update(Event::OpenMenu(1));
1040 assert_eq!(state.highlighted(), None);
1041 }
1042
1043 #[test]
1044 fn a_closed_unfocused_bar_claims_only_focus_and_alt_mnemonics() {
1045 let state = state();
1046 for named in [
1047 Named::ArrowDown,
1048 Named::ArrowLeft,
1049 Named::ArrowRight,
1050 Named::Escape,
1051 ] {
1052 assert_eq!(resolve_key(&state, &self::named(named)), None, "{named:?}");
1053 }
1054 assert_eq!(
1055 resolve_key(&state, &self::named(Named::F10)),
1056 Some(Event::Focus)
1057 );
1058 assert_eq!(
1059 resolve_key(&state, &key(Key::Named(Named::Alt), Modifiers::ALT)),
1060 Some(Event::Focus),
1061 "Alt arrives with its own modifier held"
1062 );
1063 assert_eq!(
1064 resolve_key(&state, &key(Key::Named(Named::Alt), Modifiers::empty())),
1065 Some(Event::Focus)
1066 );
1067 assert_eq!(
1068 resolve_key(&state, &key(Key::Character("e".into()), Modifiers::ALT)),
1069 Some(Event::OpenMenu(1))
1070 );
1071 assert_eq!(
1072 resolve_key(&state, &key(Key::Character("P".into()), Modifiers::ALT)),
1073 Some(Event::OpenMenu(3))
1074 );
1075 assert_eq!(
1076 resolve_key(&state, &key(Key::Character("e".into()), Modifiers::empty())),
1077 None,
1078 "bare letters need a focused bar"
1079 );
1080 assert_eq!(
1081 resolve_key(&state, &key(Key::Character("z".into()), Modifiers::ALT)),
1082 None,
1083 "letters without a menu stay free"
1084 );
1085 }
1086
1087 #[test]
1088 fn a_focused_bar_moves_opens_and_takes_bare_mnemonics() {
1089 let mut state = state();
1090 let _ = state.update(Event::Focus);
1091 assert_eq!(
1092 resolve_key(&state, &named(Named::ArrowRight)),
1093 Some(Event::Next)
1094 );
1095 assert_eq!(
1096 resolve_key(&state, &named(Named::ArrowLeft)),
1097 Some(Event::Previous)
1098 );
1099 assert_eq!(
1100 resolve_key(&state, &named(Named::ArrowDown)),
1101 Some(Event::Open)
1102 );
1103 assert_eq!(resolve_key(&state, &named(Named::Enter)), Some(Event::Open));
1104 assert_eq!(
1105 resolve_key(&state, &named(Named::Escape)),
1106 Some(Event::Close)
1107 );
1108 assert_eq!(resolve_key(&state, &named(Named::F10)), Some(Event::Focus));
1109 assert_eq!(
1110 resolve_key(&state, &key(Key::Character("v".into()), Modifiers::empty())),
1111 Some(Event::OpenMenu(2))
1112 );
1113 assert_eq!(resolve_key(&state, &named(Named::Tab)), None);
1114 }
1115
1116 #[test]
1117 fn an_open_menu_sends_left_and_right_to_submenus_first() {
1118 let mut state = state();
1119 let _ = state.update(Event::OpenMenu(0));
1120 assert_eq!(state.highlighted(), Some(NEW));
1121 assert_eq!(
1122 resolve_key(&state, &named(Named::ArrowRight)),
1123 Some(Event::Next)
1124 );
1125 assert_eq!(
1126 resolve_key(&state, &named(Named::ArrowLeft)),
1127 Some(Event::Previous)
1128 );
1129
1130 let _ = state.update(Event::Menu(0, dropdown_menu::Event::Next));
1131 assert_eq!(state.highlighted(), Some(RECENT));
1132 let right = resolve_key(&state, &named(Named::ArrowRight));
1133 assert_eq!(
1134 right,
1135 Some(Event::Menu(0, dropdown_menu::Event::OpenSubmenu))
1136 );
1137 let _ = state.update(Event::Menu(0, dropdown_menu::Event::OpenSubmenu));
1138 assert_eq!(state.highlighted(), Some(LAST));
1139 assert_eq!(
1140 resolve_key(&state, &named(Named::ArrowLeft)),
1141 Some(Event::Menu(0, dropdown_menu::Event::CloseSubmenu))
1142 );
1143 assert_eq!(
1144 resolve_key(&state, &named(Named::Escape)),
1145 Some(Event::Menu(0, dropdown_menu::Event::CloseSubmenu))
1146 );
1147 }
1148
1149 #[test]
1150 fn an_open_menu_leaves_the_rest_to_the_menu_keymap() {
1151 let mut state = state();
1152 let _ = state.update(Event::OpenMenu(0));
1153 assert_eq!(
1154 resolve_key(&state, &named(Named::ArrowDown)),
1155 Some(Event::Menu(0, dropdown_menu::Event::Next))
1156 );
1157 assert_eq!(
1158 resolve_key(&state, &named(Named::Enter)),
1159 Some(Event::Menu(0, dropdown_menu::Event::ActivateHighlighted))
1160 );
1161 assert_eq!(
1162 resolve_key(&state, &key(Key::Character("q".into()), Modifiers::empty())),
1163 Some(Event::Menu(0, dropdown_menu::Event::Typeahead('q')))
1164 );
1165 assert_eq!(
1166 resolve_key(&state, &named(Named::Escape)),
1167 Some(Event::Close)
1168 );
1169 assert_eq!(
1170 resolve_key(&state, &named(Named::F10)),
1171 Some(Event::Dismiss)
1172 );
1173 }
1174
1175 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1176 let chord: Chord = chord.parse().unwrap();
1177 keymap.resolve(chord.key(), chord.modifiers())
1178 }
1179
1180 #[test]
1181 fn default_keymap_covers_every_action() {
1182 let keymap = default_keymap();
1183 assert_eq!(press(&keymap, "F10"), Some(Action::Focus));
1184 assert_eq!(press(&keymap, "ArrowRight"), Some(Action::NextMenu));
1185 assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::PreviousMenu));
1186 assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Open));
1187 assert_eq!(press(&keymap, "Enter"), Some(Action::Open));
1188 assert_eq!(press(&keymap, "Space"), Some(Action::Open));
1189 assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1190 assert_eq!(
1191 keymap.resolve(&Key::Named(Named::Alt), Modifiers::empty()),
1192 Some(Action::Focus)
1193 );
1194 for action in <Action as keys::Action>::ALL {
1195 assert!(!keymap.chords(action).is_empty(), "{action:?}");
1196 }
1197 assert_eq!(Keymap::<Action>::defaults(), keymap);
1198 }
1199
1200 #[test]
1201 fn keymap_overrides_and_unbinding() {
1202 let keymap = default_keymap()
1203 .unbind(&Chord::named(Named::Alt))
1204 .bind("Ctrl+M".parse().unwrap(), Action::Focus);
1205 let state = state();
1206 let alt = key(Key::Named(Named::Alt), Modifiers::ALT);
1207 let menus = dropdown_menu::default_keymap();
1208 assert_eq!(state.key_event(&keymap, &menus, &alt), None);
1209 let ctrl_m = key(Key::Character("m".into()), Modifiers::CTRL);
1210 assert_eq!(
1211 state.key_event(&keymap, &menus, &ctrl_m),
1212 Some(Event::Focus)
1213 );
1214 assert_eq!(
1215 state.key_event(&Keymap::new(), &menus, &named(Named::F10)),
1216 None
1217 );
1218 }
1219
1220 #[test]
1221 fn a_closed_unfocused_bar_maps_only_focus() {
1222 let state = state();
1223 assert_eq!(Action::Focus.event(&state), Some(Event::Focus));
1224 for &action in <Action as keys::Action>::ALL {
1225 if action != Action::Focus {
1226 assert_eq!(action.event(&state), None, "{action:?}");
1227 }
1228 }
1229 }
1230
1231 #[test]
1232 fn an_open_bar_leaves_open_to_the_menu() {
1233 let mut state = state();
1234 let _ = state.update(Event::OpenMenu(1));
1235 assert_eq!(Action::Open.event(&state), None);
1236 assert_eq!(Action::Focus.event(&state), Some(Event::Dismiss));
1237 assert_eq!(Action::Close.event(&state), Some(Event::Close));
1238 }
1239
1240 #[test]
1241 fn every_action_has_a_name_and_a_sentence() {
1242 for &action in <Action as keys::Action>::ALL {
1243 assert!(!keys::Action::name(action).is_empty());
1244 assert!(keys::Action::description(action).ends_with('.'));
1245 }
1246 }
1247
1248 #[test]
1249 fn builder_defaults_and_overrides() {
1250 let state = state();
1251 let bar: Menubar<'_, u8, ()> = menubar(&state);
1252 assert_eq!(bar.width, dropdown_menu::WIDTH);
1253 assert_eq!(bar.keymap, default_keymap());
1254 assert_eq!(bar.menu_keymap, dropdown_menu::default_keymap());
1255 assert!(bar.on_event.is_none());
1256
1257 let bar: Menubar<'_, u8, ()> = menubar(&state)
1258 .width(300.0)
1259 .keymap(Keymap::new())
1260 .menu_keymap(Keymap::new())
1261 .on_event(|_| ());
1262 assert_eq!(bar.width, 300.0);
1263 assert!(bar.keymap.is_empty() && bar.menu_keymap.is_empty());
1264 assert!(bar.on_event.is_some());
1265 }
1266
1267 #[test]
1268 fn titles_use_the_accent_while_hovered_or_active() {
1269 for theme in [light(), dark()] {
1270 let tokens = Tokens::of(&theme);
1271 let idle = trigger_style(&tokens, TriggerStatus::Idle);
1272 assert_eq!(idle.background, None);
1273 assert_eq!(idle.text_color, tokens.foreground);
1274 for status in [TriggerStatus::Hovered, TriggerStatus::Active] {
1275 let style = trigger_style(&tokens, status);
1276 assert_eq!(style.background, Some(Background::Color(tokens.accent)));
1277 assert_eq!(style.text_color, tokens.accent_foreground);
1278 assert_eq!(style.border.radius, radius::SM.into());
1279 }
1280 let disabled = trigger_style(&tokens, TriggerStatus::Disabled);
1281 assert_eq!(disabled.text_color, tokens.muted_foreground);
1282 assert_eq!(disabled.background, None);
1283 assert_eq!(
1284 bar_style(&tokens).background,
1285 Some(Background::Color(tokens.background))
1286 );
1287 }
1288 }
1289
1290 #[test]
1291 fn an_active_title_stays_active_under_the_pointer() {
1292 use widget::button::Status;
1293 assert_eq!(trigger_status(Status::Hovered, true), TriggerStatus::Active);
1294 assert_eq!(
1295 trigger_status(Status::Hovered, false),
1296 TriggerStatus::Hovered
1297 );
1298 assert_eq!(
1299 trigger_status(Status::Pressed, false),
1300 TriggerStatus::Hovered
1301 );
1302 assert_eq!(trigger_status(Status::Active, false), TriggerStatus::Idle);
1303 assert_eq!(
1304 trigger_status(Status::Disabled, true),
1305 TriggerStatus::Disabled
1306 );
1307 }
1308
1309 #[test]
1310 fn mnemonics_split_the_title_around_the_first_match() {
1311 assert_eq!(
1312 split_mnemonic("Help", Some('p')),
1313 Some(("Hel".into(), "p".into(), String::new()))
1314 );
1315 assert_eq!(
1316 split_mnemonic("File", Some('f')),
1317 Some((String::new(), "F".into(), "ile".into()))
1318 );
1319 assert_eq!(split_mnemonic("File", Some('x')), None);
1320 assert_eq!(split_mnemonic("File", None), None);
1321 }
1322}