1use std::rc::Rc;
16
17use frus_core::{Color, Insets, Point, Rect, Scene, TextStyle};
18use frus_layout::{Align, Dimension, FlexDirection, Style};
19
20use crate::interaction::Status;
21use crate::menu::{menu_panel, MenuItem, PanelKind, PanelStyle, RowKeys, RowLook};
22use crate::portal::{OverlayPortal, Placement};
23use crate::theme::Theme;
24use crate::widget::Widget;
25
26#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
29pub struct MenuPath(Vec<usize>);
30
31impl MenuPath {
32 pub fn closed() -> Self {
34 Self(Vec::new())
35 }
36
37 pub fn from_indices(indices: impl Into<Vec<usize>>) -> Self {
39 Self(indices.into())
40 }
41
42 pub fn is_open(&self) -> bool {
44 !self.0.is_empty()
45 }
46
47 pub fn depth(&self) -> usize {
49 self.0.len()
50 }
51
52 pub fn at(&self, level: usize) -> Option<usize> {
54 self.0.get(level).copied()
55 }
56
57 pub fn indices(&self) -> &[usize] {
59 &self.0
60 }
61
62 pub fn opened(&self, level: usize, index: usize) -> Self {
64 let mut path = self.0[..level.min(self.0.len())].to_vec();
65 path.push(index);
66 Self(path)
67 }
68
69 pub fn truncated(&self, level: usize) -> Self {
71 Self(self.0[..level.min(self.0.len())].to_vec())
72 }
73
74 pub fn up(&self) -> Self {
76 self.truncated(self.0.len().saturating_sub(1))
77 }
78}
79
80#[derive(Clone, Copy, Debug, Default, PartialEq)]
83struct BarStyle {
84 height: Option<f32>,
85 background: Option<Color>,
86 padding: Option<Insets>,
87 item_padding: Option<Insets>,
88 text_style: Option<TextStyle>,
89 foreground: Option<Color>,
90 highlight: Option<Color>,
91 item_radius: Option<f32>,
92 item_inset: Option<f32>,
93 menus: crate::MenuTheme,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq)]
98struct BarLook {
99 height: f32,
100 background: Color,
101 padding: Insets,
102 item_padding: Insets,
103 text_style: TextStyle,
104 foreground: Color,
105 highlight: Option<Color>,
106 item_radius: f32,
107 item_inset: f32,
108}
109
110impl BarStyle {
111 fn resolve(&self, theme: &Theme) -> BarLook {
115 let t = &theme.widgets.menu_bar;
116 let desktop = theme.platform.is_desktop();
117 BarLook {
118 height: self.height.or(t.height).unwrap_or(if desktop {
119 30.0
120 } else {
121 crate::theme::MIN_TAP_TARGET
122 }),
123 background: self
124 .background
125 .or(t.background)
126 .unwrap_or(theme.scheme.surface_container),
127 padding: self.padding.or(t.padding).unwrap_or(if desktop {
128 Insets::new(0.0, 4.0, 0.0, 4.0)
129 } else {
130 Insets::ZERO
131 }),
132 item_padding: self.item_padding.or(t.item_padding).unwrap_or(if desktop {
133 Insets::new(0.0, 8.0, 0.0, 8.0)
134 } else {
135 Insets::new(0.0, BAR_PAD_X, 0.0, BAR_PAD_X)
136 }),
137 text_style: self.text_style.or(t.text_style).unwrap_or(if desktop {
138 TextStyle::new(13.0)
139 } else {
140 theme.text.label_large
141 }),
142 foreground: self
143 .foreground
144 .or(t.foreground)
145 .unwrap_or(theme.scheme.on_surface),
146 highlight: self.highlight.or(t.highlight),
147 item_radius: self.item_radius.or(t.item_radius).unwrap_or(if desktop {
148 4.0
149 } else {
150 0.0
151 }),
152 item_inset: self
153 .item_inset
154 .or(t.item_inset)
155 .unwrap_or(if desktop { 3.0 } else { 0.0 }),
156 }
157 }
158}
159
160enum Entry<Msg> {
162 Item(MenuItem<Msg>),
163 Submenu(SubmenuButton<Msg>),
164}
165
166pub struct SubmenuButton<Msg = crate::callback::Callback> {
188 label: String,
189 entries: Vec<Entry<Msg>>,
190 enabled: bool,
191}
192
193impl<Msg> SubmenuButton<Msg> {
194 pub fn new(label: impl Into<String>) -> Self {
196 Self {
197 label: label.into(),
198 entries: Vec::new(),
199 enabled: true,
200 }
201 }
202
203 pub fn item(mut self, item: MenuItem<Msg>) -> Self {
205 self.entries.push(Entry::Item(item));
206 self
207 }
208
209 pub fn submenu(mut self, submenu: SubmenuButton<Msg>) -> Self {
211 self.entries.push(Entry::Submenu(submenu));
212 self
213 }
214
215 pub fn divider(mut self) -> Self {
217 self.entries.push(Entry::Item(MenuItem::divider()));
218 self
219 }
220
221 pub fn enabled(mut self, enabled: bool) -> Self {
223 self.enabled = enabled;
224 self
225 }
226}
227
228type OnPath<Msg> = Rc<dyn Fn(MenuPath) -> Msg>;
230
231pub struct MenuBar<Msg = crate::callback::Callback> {
260 path: MenuPath,
261 on_path: OnPath<Msg>,
262 menus: Vec<SubmenuButton<Msg>>,
263 style: BarStyle,
264 children: Vec<Box<dyn Widget<Msg>>>,
265}
266
267impl<Msg: Clone + 'static> MenuBar<Msg> {
268 pub fn new(path: &MenuPath, on_path: impl Fn(MenuPath) -> Msg + 'static) -> Self {
270 Self {
271 path: path.clone(),
272 on_path: Rc::new(on_path),
273 menus: Vec::new(),
274 style: BarStyle::default(),
275 children: Vec::new(),
276 }
277 }
278
279 fn rebuilt(mut self) -> Self {
281 self.children = (0..self.menus.len()).map(|i| self.word(i)).collect();
282 self
283 }
284
285 pub(crate) fn given_height(&self) -> Option<f32> {
287 self.style.height
288 }
289
290 #[must_use]
292 pub fn height(mut self, height: f32) -> Self {
293 self.style.height = Some(height);
294 self.rebuilt()
295 }
296
297 #[must_use]
299 pub fn background(mut self, color: Color) -> Self {
300 self.style.background = Some(color);
301 self.rebuilt()
302 }
303
304 #[must_use]
306 pub fn padding(mut self, padding: Insets) -> Self {
307 self.style.padding = Some(padding);
308 self.rebuilt()
309 }
310
311 #[must_use]
313 pub fn item_padding(mut self, padding: Insets) -> Self {
314 self.style.item_padding = Some(padding);
315 self.rebuilt()
316 }
317
318 #[must_use]
321 pub fn text_style(mut self, style: TextStyle) -> Self {
322 self.style.text_style = Some(style);
323 self.rebuilt()
324 }
325
326 #[must_use]
328 pub fn foreground_color(mut self, color: Color) -> Self {
329 self.style.foreground = Some(color);
330 self.rebuilt()
331 }
332
333 #[must_use]
336 pub fn highlight_color(mut self, color: Color) -> Self {
337 self.style.highlight = Some(color);
338 self.rebuilt()
339 }
340
341 #[must_use]
343 pub fn item_radius(mut self, radius: f32) -> Self {
344 self.style.item_radius = Some(radius);
345 self.rebuilt()
346 }
347
348 #[must_use]
350 pub fn item_inset(mut self, inset: f32) -> Self {
351 self.style.item_inset = Some(inset);
352 self.rebuilt()
353 }
354
355 #[must_use]
359 pub fn menu_style(mut self, menus: crate::MenuTheme) -> Self {
360 self.style.menus = menus;
361 self.rebuilt()
362 }
363
364 pub fn menu(mut self, menu: SubmenuButton<Msg>) -> Self {
366 self.menus.push(menu);
367 self.children = (0..self.menus.len()).map(|i| self.word(i)).collect();
370 self
371 }
372
373 fn beside(&self, index: usize, step: isize) -> Option<usize> {
376 let count = self.menus.len() as isize;
377 (1..count)
378 .map(|k| (index as isize + step * k).rem_euclid(count) as usize)
379 .find(|&i| self.menus[i].enabled)
380 }
381
382 fn word(&self, index: usize) -> Box<dyn Widget<Msg>> {
384 let menu = &self.menus[index];
385 let on_path = &self.on_path;
386 let open = menu.enabled && self.path.at(0) == Some(index);
387 let press = self.path.opened(0, index);
390 let hover = (menu.enabled && self.path.is_open() && !open)
392 .then(|| on_path(self.path.opened(0, index)));
393 let arrow = |step| {
395 self.path
396 .is_open()
397 .then(|| self.beside(index, step))
398 .flatten()
399 .map(|i| on_path(self.path.opened(0, i)))
400 };
401 let word = on_enter(
402 Box::new(BarButton {
403 label: menu.label.clone(),
404 style: self.style,
405 open,
406 enabled: menu.enabled,
407 message: on_path(press),
408 left: arrow(-1),
409 right: arrow(1),
410 }),
411 hover,
412 );
413 let portal = OverlayPortal::new_boxed(word);
417 if !open {
418 return Box::new(portal);
419 }
420 let across = Across {
422 previous: self
423 .beside(index, -1)
424 .map(|i| on_path(MenuPath::closed().opened(0, i))),
425 next: self
426 .beside(index, 1)
427 .map(|i| on_path(MenuPath::closed().opened(0, i))),
428 };
429 let panel = panel(
430 &menu.entries,
431 1,
432 &self.path,
433 on_path,
434 &across,
435 &self.style.menus,
436 );
437 Box::new(
438 portal
439 .overlay_boxed(panel, Placement::Below)
440 .dismiss(on_path(MenuPath::closed())),
441 )
442 }
443}
444
445struct Across<Msg> {
447 previous: Option<Msg>,
448 next: Option<Msg>,
449}
450
451fn panel<Msg: Clone + 'static>(
453 entries: &[Entry<Msg>],
454 level: usize,
455 path: &MenuPath,
456 on_path: &OnPath<Msg>,
457 across: &Across<Msg>,
458 menus: &crate::MenuTheme,
459) -> Box<dyn Widget<Msg>> {
460 let first = entries.iter().position(|entry| match entry {
463 Entry::Item(item) => item.is_action(),
464 Entry::Submenu(sub) => sub.enabled,
465 });
466 let left = if level > 1 {
469 Some(on_path(path.truncated(level - 1)))
470 } else {
471 across.previous.clone()
472 };
473 let items: Vec<MenuItem<Msg>> = entries
475 .iter()
476 .enumerate()
477 .map(|(index, entry)| {
478 let mut row = match entry {
479 Entry::Item(item) => item.clone_row(),
480 Entry::Submenu(sub) => {
481 let mut row =
482 MenuItem::new(sub.label.clone(), on_path(path.opened(level, index)))
483 .enabled(sub.enabled);
484 row.submenu = true;
485 row.expanded = sub.enabled && path.at(level) == Some(index);
486 row
487 }
488 };
489 let right = match entry {
492 Entry::Submenu(sub) if sub.enabled => Some(on_path(path.opened(level, index))),
493 _ => across.next.clone(),
494 };
495 row.keys = RowKeys {
496 left: left.clone(),
497 right,
498 escape: Some(on_path(path.truncated(level - 1))),
500 autofocus: Some(index) == first,
501 };
502 row
503 })
504 .collect();
505 let decorate = |index: usize, row: Box<dyn Widget<Msg>>| -> Box<dyn Widget<Msg>> {
506 match &entries[index] {
507 Entry::Submenu(sub) if sub.enabled => {
508 let open = path.at(level) == Some(index);
509 let hover = (!open).then(|| on_path(path.opened(level, index)));
510 let portal = OverlayPortal::new_boxed(on_enter(row, hover));
512 if open {
513 let inner = panel(&sub.entries, level + 1, path, on_path, across, menus);
514 Box::new(portal.overlay_boxed(inner, Placement::Beside))
515 } else {
516 Box::new(portal)
517 }
518 }
519 _ => {
521 let hover = (path.depth() > level).then(|| on_path(path.truncated(level)));
522 on_enter(row, hover)
523 }
524 }
525 };
526 menu_panel(
527 &items,
528 &RowLook {
529 look: PanelStyle {
532 background: menus.background,
533 shape: menus
534 .shape
535 .or(menus.radius.map(frus_core::ShapeBorder::rounded)),
536 elevation: menus.elevation,
537 shadow_color: menus.shadow_color,
538 padding: menus.padding,
539 border: menus.border,
540 },
541 text_style: menus.text_style,
542 item_padding: menus.item_padding,
543 item_height: menus.item_height,
544 divider_height: menus.divider_height,
545 enabled: true,
546 kind: PanelKind::Bar,
547 },
548 &decorate,
549 )
550}
551
552fn on_enter<Msg: Clone + 'static>(
558 row: Box<dyn Widget<Msg>>,
559 hover: Option<Msg>,
560) -> Box<dyn Widget<Msg>> {
561 match hover {
562 Some(message) => Box::new(crate::MouseRegion::new(row).on_enter(move |_| message.clone())),
563 None => row,
564 }
565}
566
567struct BarButton<Msg> {
569 label: String,
570 style: BarStyle,
571 open: bool,
572 enabled: bool,
573 message: Msg,
574 left: Option<Msg>,
576 right: Option<Msg>,
577}
578
579const BAR_PAD_X: f32 = 12.0;
581
582impl<Msg: Clone> Widget<Msg> for BarButton<Msg> {
583 fn style(&self) -> Style {
584 self.sizing(None)
585 }
586
587 fn style_themed(&self, theme: &Theme) -> Style {
588 self.sizing(Some(theme))
589 }
590
591 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
592 &[]
593 }
594
595 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
596 let o = status.opacity;
597 let look = self.style.resolve(theme);
598 let hover = if self.enabled {
601 status
602 .hover_progress
603 .max(status.press_progress)
604 .clamp(0.0, 1.0)
605 } else {
606 0.0
607 };
608 let amount = if self.open { 1.0 } else { hover };
609 if amount > 0.0 {
610 let colour = match look.highlight {
611 Some(c) => c.with_alpha(c.a * amount),
612 None => {
613 let strength = if self.open { 0.12 } else { 0.08 * amount };
614 if look.background.a < 1.0 {
615 look.foreground.with_alpha(look.foreground.a * strength)
619 } else {
620 look.background.lerp(look.foreground, strength)
624 }
625 }
626 };
627 let inset = look.item_inset.min(bounds.height * 0.5);
628 scene.draw_rect(
629 Rect::new(
630 bounds.x,
631 bounds.y + inset,
632 bounds.width,
633 bounds.height - 2.0 * inset,
634 ),
635 colour.fade(o),
636 look.item_radius,
637 0.0,
638 frus_core::Color::TRANSPARENT,
639 );
640 }
641 let style = look.text_style.resolved();
642 let ink = if self.enabled {
643 look.foreground
644 } else {
645 crate::disabled::disabled_content(theme)
646 };
647 let y = bounds.y + (bounds.height - style.line_height()) * 0.5;
648 scene.text(
649 Point::new(bounds.x + look.item_padding.left, y),
650 self.label.clone(),
651 &style,
652 ink.fade(o),
653 );
654 }
655
656 fn on_click(&self) -> Option<Msg> {
657 self.enabled.then(|| self.message.clone())
658 }
659
660 fn focusable(&self) -> bool {
661 self.enabled
662 }
663
664 fn on_key(&self, key: &crate::interaction::Key) -> crate::interaction::KeyResponse<Msg> {
665 use crate::interaction::{Key, KeyResponse};
666 if !self.enabled {
668 return KeyResponse::Ignored;
669 }
670 let sent = match key {
671 Key::Left { .. } => self.left.clone(),
672 Key::Right { .. } => self.right.clone(),
673 _ => None,
674 };
675 match sent {
676 Some(message) => KeyResponse::Handled(Some(message)),
677 None => KeyResponse::Ignored,
678 }
679 }
680
681 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
682 let semantics =
683 frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
684 Some(if self.enabled {
685 semantics.clickable()
686 } else {
687 semantics.disabled(true)
688 })
689 }
690
691 fn debug_name(&self) -> &'static str {
692 "MenuBarButton"
693 }
694}
695
696impl<Msg> BarButton<Msg> {
697 fn sizing(&self, theme: Option<&Theme>) -> Style {
698 let fallback = Theme::default();
699 let look = self.style.resolve(theme.unwrap_or(&fallback));
700 let style = look.text_style.resolved();
701 let width = frus_text::measure_resolved(&self.label, &style).width
702 + look.item_padding.left
703 + look.item_padding.right;
704 Style {
705 width: Dimension::Length(width.ceil()),
706 height: Dimension::Length(look.height),
707 ..Style::default()
708 }
709 }
710}
711
712impl<Msg: Clone> Widget<Msg> for MenuBar<Msg> {
713 fn style(&self) -> Style {
714 Widget::<Msg>::style_themed(self, &Theme::default())
715 }
716
717 fn style_themed(&self, theme: &Theme) -> Style {
718 let look = self.style.resolve(theme);
719 Style {
720 flex_direction: FlexDirection::Row,
721 align: Align::Center,
722 height: Dimension::Length(look.height),
723 padding: look.padding,
724 ..Style::default()
725 }
726 }
727
728 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
729 &self.children
730 }
731
732 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
733 let look = self.style.resolve(theme);
734 scene.fill_rect(bounds, look.background.fade(status.opacity));
735 }
736
737 fn on_click(&self) -> Option<Msg> {
738 None
739 }
740
741 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
743 crate::widget::FillAxes {
744 horizontal: true,
745 vertical: false,
746 }
747 }
748
749 fn debug_name(&self) -> &'static str {
750 "MenuBar"
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use crate::{build_ui, Runtime};
758 use frus_core::Size;
759
760 #[derive(Clone, Debug, PartialEq)]
761 enum Msg {
762 Menu(MenuPath),
763 New,
764 Quit,
765 Recent(usize),
766 Wrap,
767 }
768
769 fn bar(path: &MenuPath) -> MenuBar<Msg> {
771 MenuBar::new(path, Msg::Menu)
772 .menu(
773 SubmenuButton::new("File")
774 .item(MenuItem::new("New", Msg::New).shortcut("Ctrl+N"))
775 .divider()
776 .submenu(
777 SubmenuButton::new("Open recent")
778 .item(MenuItem::new("notes.txt", Msg::Recent(0)))
779 .item(MenuItem::new("todo.md", Msg::Recent(1))),
780 )
781 .item(MenuItem::new("Quit", Msg::Quit)),
782 )
783 .menu(SubmenuButton::new("View").item(MenuItem::checked("Word wrap", true, Msg::Wrap)))
784 }
785
786 const SIZE: Size = Size::new(800.0, 600.0);
787
788 fn ui(root: &MenuBar<Msg>) -> crate::Ui<Msg> {
789 build_ui(root, SIZE, &Runtime::default(), &Theme::light())
790 }
791
792 fn press(root: &MenuBar<Msg>, x: f32, y: f32) -> Option<Msg> {
794 let ui = ui(root);
795 ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id))
796 }
797
798 fn hover(root: &MenuBar<Msg>, x: f32, y: f32) -> Option<Msg> {
800 let ui = ui(root);
801 ui.hover_regions_at(Point::new(x, y))
802 .into_iter()
803 .find_map(|(id, _, _)| {
804 crate::ui::find_widget(root as &dyn Widget<Msg>, id)?.on_hover_event(
805 crate::HoverEvent::Enter {
806 local: Point::new(1.0, 1.0),
807 },
808 )
809 })
810 }
811
812 fn text_at(root: &MenuBar<Msg>, label: &str) -> Option<Point> {
814 let ui = ui(root);
815 let found = ui.scene().primitives().iter().find_map(|p| match p {
816 frus_core::Primitive::Text { position, text, .. } if text == label => Some(*position),
817 _ => None,
818 });
819 found
820 }
821
822 fn path(indices: &[usize]) -> MenuPath {
823 MenuPath::from_indices(indices.to_vec())
824 }
825
826 #[test]
827 fn a_path_opens_and_closes_by_level() {
828 let open = MenuPath::closed().opened(0, 2);
829 assert_eq!(open.indices(), &[2]);
830 let deeper = open.opened(1, 3);
831 assert_eq!(deeper.indices(), &[2, 3]);
832 assert_eq!(
833 deeper.opened(0, 1).indices(),
834 &[1],
835 "another menu closes the submenu"
836 );
837 assert_eq!(deeper.truncated(1).indices(), &[2]);
838 assert_eq!(deeper.up().indices(), &[2]);
839 assert!(!deeper.truncated(0).is_open());
840 assert_eq!(deeper.at(1), Some(3));
841 assert_eq!(deeper.depth(), 2);
842 }
843
844 #[test]
846 fn pressing_a_word_opens_its_menu() {
847 let closed = bar(&MenuPath::closed());
848 let file = text_at(&closed, "File").expect("the word");
849 assert!(text_at(&closed, "Quit").is_none(), "no menu is showing");
850 assert_eq!(
851 press(&closed, file.x + 5.0, 20.0),
852 Some(Msg::Menu(path(&[0])))
853 );
854 let view = text_at(&closed, "View").expect("the word");
855 assert_eq!(
856 press(&closed, view.x + 5.0, 20.0),
857 Some(Msg::Menu(path(&[1])))
858 );
859 assert_eq!(hover(&closed, view.x + 5.0, 20.0), None);
861 }
862
863 #[test]
866 fn an_open_menu_shows_its_rows_and_closes() {
867 let open = bar(&path(&[0]));
868 let file = text_at(&open, "File").expect("the word");
869 let quit = text_at(&open, "Quit").expect("a row");
870 assert!(quit.y > 48.0, "under the bar: {quit:?}");
871 assert_eq!(press(&open, quit.x + 5.0, quit.y + 5.0), Some(Msg::Quit));
872 assert_eq!(
873 press(&open, file.x + 5.0, 20.0),
874 Some(Msg::Menu(MenuPath::closed()))
875 );
876 assert_eq!(
877 press(&open, 700.0, 500.0),
878 Some(Msg::Menu(MenuPath::closed()))
879 );
880 }
881
882 #[test]
884 fn hovering_another_word_switches_menus() {
885 let open = bar(&path(&[0]));
886 let view = text_at(&open, "View").expect("the word");
887 assert_eq!(
888 hover(&open, view.x + 5.0, 20.0),
889 Some(Msg::Menu(path(&[1])))
890 );
891 let file = text_at(&open, "File").expect("the word");
892 assert_eq!(hover(&open, file.x + 5.0, 20.0), None, "already open");
893 }
894
895 #[test]
898 fn a_submenu_opens_on_hover_beside_its_row() {
899 let open = bar(&path(&[0]));
900 let recent = text_at(&open, "Open recent").expect("the row");
901 assert!(text_at(&open, "notes.txt").is_none());
902 assert_eq!(
903 hover(&open, recent.x + 5.0, recent.y + 5.0),
904 Some(Msg::Menu(path(&[0, 2])))
905 );
906 assert_eq!(
907 press(&open, recent.x + 5.0, recent.y + 5.0),
908 Some(Msg::Menu(path(&[0, 2])))
909 );
910
911 let deeper = bar(&path(&[0, 2]));
912 let row = text_at(&deeper, "Open recent").expect("the row");
913 let notes = text_at(&deeper, "notes.txt").expect("the submenu");
914 assert!(
915 notes.x > row.x + 100.0,
916 "beside the row: {notes:?} vs {row:?}"
917 );
918 assert!(
919 (notes.y - row.y).abs() < 10.0,
920 "level with it: {notes:?} vs {row:?}"
921 );
922 assert_eq!(
923 press(&deeper, notes.x + 5.0, notes.y + 5.0),
924 Some(Msg::Recent(0))
925 );
926 let new = text_at(&deeper, "New").expect("a row");
927 assert_eq!(
928 hover(&deeper, new.x + 5.0, new.y + 5.0),
929 Some(Msg::Menu(path(&[0])))
930 );
931 }
932
933 #[test]
935 fn a_disabled_menu_does_not_open() {
936 let bar = MenuBar::new(&MenuPath::closed(), Msg::Menu).menu(
937 SubmenuButton::new("Edit")
938 .item(MenuItem::new("New", Msg::New))
939 .enabled(false),
940 );
941 let edit = text_at(&bar, "Edit").expect("still shown");
942 assert_eq!(press(&bar, edit.x + 5.0, 20.0), None);
943 let named = MenuBar::new(&path(&[0]), Msg::Menu).menu(
945 SubmenuButton::new("Edit")
946 .item(MenuItem::new("New", Msg::New))
947 .enabled(false),
948 );
949 assert!(text_at(&named, "New").is_none());
950 }
951
952 #[test]
955 fn a_submenu_with_no_room_opens_on_the_other_side() {
956 let crowded = |path: &MenuPath| {
957 let filler = |word: &str| SubmenuButton::new(word).item(MenuItem::new("x", Msg::New));
958 MenuBar::new(path, Msg::Menu)
959 .menu(filler("Alpha"))
960 .menu(filler("Bravo"))
961 .menu(filler("Charlie"))
962 .menu(filler("Delta"))
963 .menu(
964 SubmenuButton::new("File").submenu(
965 SubmenuButton::new("Open recent")
966 .item(MenuItem::new("notes.txt", Msg::Recent(0))),
967 ),
968 )
969 };
970 let root = crowded(&path(&[4, 0]));
971 let ui = build_ui(
972 &root,
973 Size::new(560.0, 600.0),
974 &Runtime::default(),
975 &Theme::light(),
976 );
977 let at = |label: &str| {
978 ui.scene()
979 .primitives()
980 .iter()
981 .find_map(|p| match p {
982 frus_core::Primitive::Text { position, text, .. } if text == label => {
983 Some(*position)
984 }
985 _ => None,
986 })
987 .expect(label)
988 };
989 let (row, notes) = (at("Open recent"), at("notes.txt"));
990 assert!(notes.x < row.x, "on the start side: {notes:?} vs {row:?}");
991 assert!(notes.x >= 0.0, "inside the window: {notes:?}");
992 }
993
994 fn row_id(root: &MenuBar<Msg>, label: &str) -> crate::interaction::WidgetId {
996 let at = text_at(root, label).expect(label);
997 ui(root)
998 .hit(Point::new(at.x + 5.0, at.y + 5.0))
999 .expect("a row")
1000 }
1001
1002 fn key_on(root: &MenuBar<Msg>, label: &str, key: crate::interaction::Key) -> Option<Msg> {
1004 let id = row_id(root, label);
1005 match crate::ui::find_widget(root as &dyn Widget<Msg>, id)?.on_key(&key) {
1006 crate::interaction::KeyResponse::Handled(message) => message,
1007 _ => None,
1008 }
1009 }
1010
1011 fn escape_from(root: &MenuBar<Msg>, label: &str) -> Option<Msg> {
1013 let id = row_id(root, label);
1014 let path = crate::ui::find_path(root as &dyn Widget<Msg>, id);
1015 path.iter()
1016 .rev()
1017 .find_map(|w| match w.on_key(&crate::interaction::Key::Escape) {
1018 crate::interaction::KeyResponse::Handled(message) => Some(message),
1019 _ => None,
1020 })
1021 .flatten()
1022 }
1023
1024 const LEFT: crate::interaction::Key = crate::interaction::Key::Left {
1025 shift: false,
1026 word: false,
1027 };
1028 const RIGHT: crate::interaction::Key = crate::interaction::Key::Right {
1029 shift: false,
1030 word: false,
1031 };
1032
1033 #[test]
1036 fn the_first_usable_row_takes_the_focus() {
1037 let open = bar(&path(&[0]));
1038 let focus: Vec<_> = ui(&open).autofocus_ids().collect();
1039 assert_eq!(focus, vec![row_id(&open, "New")]);
1040
1041 let skipping = MenuBar::new(&path(&[0]), Msg::Menu).menu(
1042 SubmenuButton::new("File")
1043 .divider()
1044 .item(MenuItem::new("Gone", Msg::New).enabled(false))
1045 .item(MenuItem::new("Quit", Msg::Quit)),
1046 );
1047 let focus: Vec<_> = ui(&skipping).autofocus_ids().collect();
1048 assert_eq!(focus, vec![row_id(&skipping, "Quit")]);
1049
1050 let deeper = bar(&path(&[0, 2]));
1051 let focus: Vec<_> = ui(&deeper).autofocus_ids().collect();
1052 assert!(
1053 focus.contains(&row_id(&deeper, "notes.txt")),
1054 "the submenu's too"
1055 );
1056 }
1057
1058 #[test]
1062 fn the_arrows_move_through_the_menus() {
1063 let open = bar(&path(&[0]));
1064 assert_eq!(
1065 key_on(&open, "Open recent", RIGHT),
1066 Some(Msg::Menu(path(&[0, 2])))
1067 );
1068 assert_eq!(key_on(&open, "New", RIGHT), Some(Msg::Menu(path(&[1]))));
1069 assert_eq!(
1070 key_on(&open, "New", LEFT),
1071 Some(Msg::Menu(path(&[1]))),
1072 "round the end"
1073 );
1074
1075 let deeper = bar(&path(&[0, 2]));
1076 assert_eq!(
1077 key_on(&deeper, "notes.txt", LEFT),
1078 Some(Msg::Menu(path(&[0])))
1079 );
1080 assert_eq!(
1081 key_on(&deeper, "notes.txt", RIGHT),
1082 Some(Msg::Menu(path(&[1])))
1083 );
1084 }
1085
1086 #[test]
1089 fn the_arrows_on_the_bar_switch_menus_only_while_one_is_open() {
1090 let sent = |w: &dyn Widget<Msg>, key| match w.on_key(&key) {
1091 crate::interaction::KeyResponse::Handled(m) => m,
1092 _ => None,
1093 };
1094 let open = bar(&path(&[0]));
1096 let word = open.children()[0].children()[0].as_ref();
1097 assert_eq!(sent(word, RIGHT), Some(Msg::Menu(path(&[1]))));
1098 assert_eq!(
1099 sent(word, LEFT),
1100 Some(Msg::Menu(path(&[1]))),
1101 "round the end"
1102 );
1103
1104 let closed = bar(&MenuPath::closed());
1105 assert_eq!(sent(closed.children()[0].as_ref(), RIGHT), None);
1106 }
1107
1108 #[test]
1110 fn escape_closes_one_level() {
1111 let deeper = bar(&path(&[0, 2]));
1112 assert_eq!(
1113 escape_from(&deeper, "notes.txt"),
1114 Some(Msg::Menu(path(&[0])))
1115 );
1116 let open = bar(&path(&[0]));
1117 assert_eq!(
1118 escape_from(&open, "Quit"),
1119 Some(Msg::Menu(MenuPath::closed()))
1120 );
1121 }
1122
1123 #[test]
1127 fn a_row_with_its_submenu_shut_keeps_the_menus_width() {
1128 let open = bar(&path(&[0]));
1129 let recent = text_at(&open, "Open recent").expect("the row");
1130 assert_eq!(
1131 press(&open, recent.x + 120.0, recent.y + 5.0),
1132 Some(Msg::Menu(path(&[0, 2])))
1133 );
1134 }
1135
1136 #[test]
1141 fn a_row_with_its_submenu_open_keeps_the_menus_width() {
1142 let deeper = bar(&path(&[0, 2]));
1143 let ui = ui(&deeper);
1144 let recent = text_at(&deeper, "Open recent").expect("the row");
1145 let quit = text_at(&deeper, "Quit").expect("another row");
1146 let id_at = |x: f32, y: f32| ui.hit(Point::new(x, y + 5.0));
1147 let end = recent.x + 120.0;
1149 assert_eq!(
1150 press(&deeper, end, recent.y + 5.0),
1151 Some(Msg::Menu(path(&[0, 2])))
1152 );
1153 assert!(id_at(end, quit.y).is_some(), "the other row reaches as far");
1154 let chevron = ui
1156 .scene()
1157 .primitives()
1158 .iter()
1159 .find_map(|p| match p {
1160 p @ frus_core::Primitive::Path { .. } => {
1161 let b = p.bounds();
1162 ((b.y - recent.y).abs() < 16.0).then_some(b)
1163 }
1164 _ => None,
1165 })
1166 .expect("a chevron on the row");
1167 assert!(chevron.x > recent.x + 60.0, "at the row's end: {chevron:?}");
1168 }
1169
1170 fn scene_in(root: &MenuBar<Msg>, theme: &Theme) -> Vec<frus_core::Primitive> {
1172 build_ui(root, SIZE, &Runtime::default(), theme)
1173 .scene()
1174 .primitives()
1175 .to_vec()
1176 }
1177
1178 fn rects_at(scene: &[frus_core::Primitive], x: f32, y: f32) -> Vec<(Rect, Color, f32)> {
1180 scene
1181 .iter()
1182 .filter_map(|p| match p {
1183 frus_core::Primitive::Rect {
1184 rect,
1185 color,
1186 radius,
1187 ..
1188 } if rect.contains(Point::new(x, y)) => Some((*rect, *color, radius.top_left)),
1189 _ => None,
1190 })
1191 .collect()
1192 }
1193
1194 fn word(scene: &[frus_core::Primitive], label: &str) -> (Point, f32, Color) {
1196 scene
1197 .iter()
1198 .find_map(|p| match p {
1199 frus_core::Primitive::Text {
1200 position,
1201 text,
1202 size,
1203 color,
1204 ..
1205 } if text == label => Some((*position, *size, *color)),
1206 _ => None,
1207 })
1208 .expect("the words are painted")
1209 }
1210
1211 fn on(platform: frus_core::TargetPlatform) -> Theme {
1212 Theme::light().with_platform(platform)
1213 }
1214
1215 #[test]
1218 fn a_desktop_bar_is_a_desktops_and_a_phones_a_phones() {
1219 let closed = bar(&MenuPath::closed());
1220 let desk = scene_in(&closed, &on(frus_core::TargetPlatform::Windows));
1221 let surface = rects_at(&desk, 400.0, 2.0);
1222 assert_eq!(surface[0].0.height, 30.0, "the bar");
1223 let (file, size, _) = word(&desk, "File");
1224 assert_eq!(size, 13.0);
1225 assert_eq!(file.x, 4.0 + 8.0, "the bar's room, then the word's");
1226 let (view, _, _) = word(&desk, "View");
1227 let file_width = frus_text::measure_resolved("File", &TextStyle::new(13.0).resolved())
1228 .width
1229 .ceil();
1230 assert_eq!(
1231 view.x,
1232 file.x + file_width + 16.0,
1233 "8 after one, 8 before the next"
1234 );
1235
1236 let phone = scene_in(&closed, &on(frus_core::TargetPlatform::Android));
1237 let surface = rects_at(&phone, 400.0, 2.0);
1238 assert_eq!(surface[0].0.height, crate::theme::MIN_TAP_TARGET);
1239 let (file, size, _) = word(&phone, "File");
1240 assert_eq!(size, Theme::light().text.label_large.resolved().size);
1241 assert_eq!(file.x, BAR_PAD_X);
1242 }
1243
1244 #[test]
1248 fn the_open_word_and_the_open_row_keep_their_highlight() {
1249 let theme = on(frus_core::TargetPlatform::Linux);
1250 let open = scene_in(&bar(&path(&[0])), &theme);
1251 let (file, _, _) = word(&open, "File");
1252 let under = rects_at(&open, file.x + 2.0, 15.0);
1253 let (rect, colour, radius) = *under.last().expect("a highlight");
1254 assert_eq!((rect.y, rect.height, radius), (3.0, 24.0, 4.0));
1255 let surface = theme.scheme.surface_container;
1256 assert_eq!(colour, surface.lerp(theme.scheme.on_surface, 0.12));
1257 let (view, _, _) = word(&open, "View");
1258 assert_eq!(
1259 rects_at(&open, view.x + 2.0, 15.0).len(),
1260 1,
1261 "the bar alone under a word at rest"
1262 );
1263
1264 let deeper = scene_in(&bar(&path(&[0, 2])), &theme);
1265 let (row, _, _) = word(&deeper, "Open recent");
1266 let (quit, _, _) = word(&deeper, "Quit");
1267 let lit = rects_at(&deeper, row.x, row.y + 4.0);
1268 let plain = rects_at(&deeper, quit.x, quit.y + 4.0);
1269 assert_eq!(
1270 lit.len(),
1271 plain.len() + 1,
1272 "the open row is lit, the others are not"
1273 );
1274 }
1275
1276 #[test]
1280 fn the_highlight_leans_toward_the_words() {
1281 let theme = on(frus_core::TargetPlatform::Linux);
1282 let accent = Color::rgb(0.0, 0.2, 0.5);
1283 let open = scene_in(
1284 &bar(&path(&[0]))
1285 .background(accent)
1286 .foreground_color(Color::WHITE),
1287 &theme,
1288 );
1289 let (file, _, _) = word(&open, "File");
1290 let (_, colour, _) = *rects_at(&open, file.x + 2.0, 15.0)
1291 .last()
1292 .expect("a highlight");
1293 assert_eq!(colour, accent.lerp(Color::WHITE, 0.12));
1294 }
1295
1296 #[test]
1300 fn a_see_through_bar_s_highlight_is_a_wash_of_its_words() {
1301 let theme = on(frus_core::TargetPlatform::Linux);
1302 let open = scene_in(
1303 &bar(&path(&[0]))
1304 .background(Color::TRANSPARENT)
1305 .foreground_color(Color::WHITE),
1306 &theme,
1307 );
1308 let (file, _, _) = word(&open, "File");
1309 let (_, colour, _) = *rects_at(&open, file.x + 2.0, 15.0)
1310 .iter()
1311 .rev()
1312 .find(|(_, colour, _)| colour.a > 0.0)
1313 .expect("a highlight");
1314 assert_eq!(colour, Color::WHITE.with_alpha(0.12));
1315 }
1316
1317 #[test]
1320 fn every_part_of_the_bar_can_be_said() {
1321 let red = Color::rgb(0.8, 0.1, 0.1);
1322 let green = Color::rgb(0.1, 0.7, 0.2);
1323 let blue = Color::rgb(0.1, 0.2, 0.9);
1324 let edge = frus_core::BorderSide {
1325 color: green,
1326 width: 2.0,
1327 };
1328 let styled = bar(&path(&[0]))
1330 .height(40.0)
1331 .background(red)
1332 .padding(Insets::new(0.0, 10.0, 0.0, 10.0))
1333 .item_padding(Insets::new(0.0, 20.0, 0.0, 20.0))
1334 .text_style(TextStyle::new(16.0))
1335 .foreground_color(green)
1336 .highlight_color(blue)
1337 .item_radius(0.0)
1338 .item_inset(0.0)
1339 .menu_style(crate::MenuTheme {
1340 item_height: Some(32.0),
1341 border: Some(edge),
1342 ..Default::default()
1343 });
1344 let scene = scene_in(&styled, &on(frus_core::TargetPlatform::Linux));
1345 let surface = rects_at(&scene, 400.0, 2.0);
1346 assert_eq!((surface[0].0.height, surface[0].1), (40.0, red));
1347 let (file, size, ink) = word(&scene, "File");
1348 assert_eq!((file.x, size, ink), (30.0, 16.0, green));
1349 let (rect, colour, radius) = *rects_at(&scene, file.x, 20.0).last().expect("lit");
1350 assert_eq!(
1351 (rect.y, rect.height, colour, radius),
1352 (0.0, 40.0, blue, 0.0)
1353 );
1354 let (new, _, _) = word(&scene, "New");
1355 let (quit, _, _) = word(&scene, "Quit");
1356 assert!(scene.iter().any(|p| matches!(
1357 p,
1358 frus_core::Primitive::Rect { border_width, border_color, .. }
1359 if *border_width == 2.0 && *border_color == green
1360 )));
1361 let last = bar(&MenuPath::closed()).text_style(TextStyle::new(18.0));
1364 let scene_last = scene_in(&last, &on(frus_core::TargetPlatform::Linux));
1365 assert_eq!(word(&scene_last, "File").1, 18.0);
1366 let rule = quit.y - new.y - 2.0 * 32.0;
1367 assert_eq!(
1368 rule,
1369 crate::MenuTheme::desktop(&Theme::light())
1370 .divider_height
1371 .unwrap()
1372 );
1373 }
1374
1375 #[test]
1377 fn the_theme_answers_what_the_caller_left_unsaid() {
1378 let mut theme = on(frus_core::TargetPlatform::Linux);
1379 theme.widgets.menu_bar = crate::MenuBarTheme {
1380 height: Some(36.0),
1381 foreground: Some(Color::rgb(0.5, 0.0, 0.5)),
1382 menus: crate::MenuTheme {
1383 item_height: Some(28.0),
1384 ..Default::default()
1385 },
1386 ..Default::default()
1387 };
1388 let scene = scene_in(&bar(&path(&[0])), &theme);
1389 assert_eq!(rects_at(&scene, 400.0, 2.0)[0].0.height, 36.0);
1390 assert_eq!(word(&scene, "File").2, Color::rgb(0.5, 0.0, 0.5));
1391 let (new, _, _) = word(&scene, "New");
1392 let (rule_after, _, _) = word(&scene, "Open recent");
1393 let (quit, _, _) = word(&scene, "Quit");
1394 assert_eq!(quit.y - rule_after.y, 28.0, "the theme's rows");
1395 assert!(rule_after.y > new.y);
1396
1397 let said = bar(&path(&[0])).height(44.0);
1398 let scene = scene_in(&said, &theme);
1399 assert_eq!(rects_at(&scene, 400.0, 2.0)[0].0.height, 44.0);
1400 }
1401
1402 #[test]
1405 fn a_desktop_bars_menus_are_a_desktops() {
1406 let open = bar(&path(&[0]));
1407 let theme = on(frus_core::TargetPlatform::MacOs);
1408 let scene = scene_in(&open, &theme);
1409 let (row, size, _) = word(&scene, "Open recent");
1410 let (quit, _, _) = word(&scene, "Quit");
1411 assert_eq!((quit.y - row.y, size), (26.0, 13.0));
1412 assert!(scene.iter().any(|p| matches!(
1413 p,
1414 frus_core::Primitive::Rect { border_width, border_color, .. }
1415 if *border_width == 1.0 && *border_color == theme.scheme.outline_variant
1416 )));
1417 let (new, _, _) = word(&scene, "New");
1418 let line = scene.iter().find_map(|p| match p {
1419 frus_core::Primitive::Rect { rect, .. }
1420 if rect.height == 1.0 && rect.y > new.y && rect.y < row.y =>
1421 {
1422 Some(*rect)
1423 }
1424 _ => None,
1425 });
1426 assert!(
1427 line.is_some_and(|l| l.width > 100.0),
1428 "the rule is a line across the menu"
1429 );
1430
1431 let phone = scene_in(&open, &on(frus_core::TargetPlatform::Android));
1432 let (row, _, _) = word(&phone, "Open recent");
1433 let (quit, _, _) = word(&phone, "Quit");
1434 assert!(quit.y - row.y >= crate::theme::MIN_TAP_TARGET);
1435 }
1436}