1use crate::get_global_color;
2use eframe::egui::{self, Color32, Context, Id, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
3
4#[derive(Clone, Copy, PartialEq)]
6pub enum Corner {
7 TopLeft,
8 TopRight,
9 BottomLeft,
10 BottomRight,
11}
12
13#[derive(Clone, Copy, PartialEq)]
15pub enum FocusState {
16 None,
17 ListRoot,
18 FirstItem,
19}
20
21#[derive(Clone, Copy, PartialEq)]
23pub enum Positioning {
24 Absolute,
25 Fixed,
26 Document,
27 Popover,
28}
29
30#[derive(Clone, Debug)]
45pub struct MenuStyle {
46 pub background_color: Option<Color32>,
48 pub shadow_color: Option<Color32>,
50 pub surface_tint_color: Option<Color32>,
52 pub elevation: Option<f32>,
54 pub padding: Option<f32>,
56 pub min_width: Option<f32>,
58 pub max_width: Option<f32>,
60 pub corner_radius: Option<f32>,
62}
63
64impl Default for MenuStyle {
65 fn default() -> Self {
66 Self {
67 background_color: None,
68 shadow_color: None,
69 surface_tint_color: None,
70 elevation: None,
71 padding: None,
72 min_width: None,
73 max_width: None,
74 corner_radius: None,
75 }
76 }
77}
78
79impl MenuStyle {
80 pub fn merge(&self, other: &MenuStyle) -> MenuStyle {
83 MenuStyle {
84 background_color: self.background_color.or(other.background_color),
85 shadow_color: self.shadow_color.or(other.shadow_color),
86 surface_tint_color: self.surface_tint_color.or(other.surface_tint_color),
87 elevation: self.elevation.or(other.elevation),
88 padding: self.padding.or(other.padding),
89 min_width: self.min_width.or(other.min_width),
90 max_width: self.max_width.or(other.max_width),
91 corner_radius: self.corner_radius.or(other.corner_radius),
92 }
93 }
94
95 fn resolve(&self) -> ResolvedMenuStyle {
97 ResolvedMenuStyle {
98 background_color: self
99 .background_color
100 .unwrap_or_else(|| get_global_color("surfaceContainer")),
101 shadow_color: self
102 .shadow_color
103 .unwrap_or_else(|| get_global_color("shadow")),
104 elevation: self.elevation.unwrap_or(3.0),
105 padding: self.padding.unwrap_or(8.0),
106 _min_width: self.min_width.unwrap_or(112.0),
107 max_width: self.max_width.unwrap_or(280.0),
108 corner_radius: self.corner_radius.unwrap_or(4.0),
109 }
110 }
111}
112
113struct ResolvedMenuStyle {
115 background_color: Color32,
116 shadow_color: Color32,
117 elevation: f32,
118 padding: f32,
119 _min_width: f32,
120 max_width: f32,
121 corner_radius: f32,
122}
123
124#[derive(Clone, Debug, Default)]
129pub struct MenuThemeData {
130 pub style: Option<MenuStyle>,
132}
133
134#[derive(Clone, Debug, Default)]
139pub struct MenuBarThemeData {
140 pub style: Option<MenuStyle>,
142}
143
144#[derive(Clone, Debug)]
160pub struct MenuButtonThemeData {
161 pub foreground_color: Option<Color32>,
163 pub icon_color: Option<Color32>,
165 pub disabled_foreground_color: Option<Color32>,
167 pub disabled_icon_color: Option<Color32>,
169 pub hover_overlay_opacity: Option<f32>,
171 pub pressed_overlay_opacity: Option<f32>,
173 pub text_font: Option<egui::FontId>,
175 pub min_height: Option<f32>,
177 pub icon_size: Option<f32>,
179 pub padding_horizontal: Option<f32>,
181}
182
183impl Default for MenuButtonThemeData {
184 fn default() -> Self {
185 Self {
186 foreground_color: None,
187 icon_color: None,
188 disabled_foreground_color: None,
189 disabled_icon_color: None,
190 hover_overlay_opacity: None,
191 pressed_overlay_opacity: None,
192 text_font: None,
193 min_height: None,
194 icon_size: None,
195 padding_horizontal: None,
196 }
197 }
198}
199
200impl MenuButtonThemeData {
201 fn resolve(&self) -> ResolvedMenuButtonTheme {
203 let on_surface = get_global_color("onSurface");
204 let on_surface_variant = get_global_color("onSurfaceVariant");
205 let disabled_color = Color32::from_rgba_premultiplied(
206 on_surface.r(),
207 on_surface.g(),
208 on_surface.b(),
209 97, );
211
212 ResolvedMenuButtonTheme {
213 foreground_color: self.foreground_color.unwrap_or(on_surface),
214 icon_color: self.icon_color.unwrap_or(on_surface_variant),
215 disabled_foreground_color: self.disabled_foreground_color.unwrap_or(disabled_color),
216 disabled_icon_color: self.disabled_icon_color.unwrap_or(disabled_color),
217 hover_overlay_opacity: self.hover_overlay_opacity.unwrap_or(0.08),
218 pressed_overlay_opacity: self.pressed_overlay_opacity.unwrap_or(0.10),
219 text_font: self.text_font.clone().unwrap_or_default(),
220 min_height: self.min_height.unwrap_or(48.0),
221 icon_size: self.icon_size.unwrap_or(24.0),
222 padding_horizontal: self.padding_horizontal.unwrap_or(12.0),
223 }
224 }
225}
226
227struct ResolvedMenuButtonTheme {
229 foreground_color: Color32,
230 icon_color: Color32,
231 disabled_foreground_color: Color32,
232 disabled_icon_color: Color32,
233 hover_overlay_opacity: f32,
234 pressed_overlay_opacity: f32,
235 text_font: egui::FontId,
236 min_height: f32,
237 icon_size: f32,
238 padding_horizontal: f32,
239}
240
241#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
266pub struct MaterialMenu<'a> {
267 id: Id,
269 open: &'a mut bool,
271 anchor_rect: Option<Rect>,
273 items: Vec<MenuItem<'a>>,
275 anchor_corner: Corner,
277 menu_corner: Corner,
279 default_focus: FocusState,
281 positioning: Positioning,
283 quick: bool,
285 has_overflow: bool,
287 stay_open_on_outside_click: bool,
289 stay_open_on_focusout: bool,
291 skip_restore_focus: bool,
293 x_offset: f32,
295 y_offset: f32,
297 no_horizontal_flip: bool,
299 no_vertical_flip: bool,
301 typeahead_delay: f32,
303 list_tab_index: i32,
305 menu_style: Option<MenuStyle>,
307 button_theme: Option<MenuButtonThemeData>,
309}
310
311pub struct MenuItem<'a> {
313 text: String,
315 leading_icon: Option<String>,
317 trailing_icon: Option<String>,
319 enabled: bool,
321 divider_after: bool,
323 action: Option<Box<dyn Fn() + 'a>>,
325}
326
327impl<'a> MaterialMenu<'a> {
328 pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
342 Self {
343 id: id.into(),
344 open,
345 anchor_rect: None,
346 items: Vec::new(),
347 anchor_corner: Corner::BottomLeft,
349 menu_corner: Corner::TopLeft,
350 default_focus: FocusState::None,
351 positioning: Positioning::Absolute,
352 quick: false,
353 has_overflow: false,
354 stay_open_on_outside_click: false,
355 stay_open_on_focusout: false,
356 skip_restore_focus: false,
357 x_offset: 0.0,
358 y_offset: 0.0,
359 no_horizontal_flip: false,
360 no_vertical_flip: false,
361 typeahead_delay: 200.0,
362 list_tab_index: -1,
363 menu_style: None,
364 button_theme: None,
365 }
366 }
367
368 pub fn anchor_rect(mut self, rect: Rect) -> Self {
385 self.anchor_rect = Some(rect);
386 self
387 }
388
389 pub fn item(mut self, item: MenuItem<'a>) -> Self {
403 self.items.push(item);
404 self
405 }
406
407 pub fn style(mut self, style: MenuStyle) -> Self {
412 self.menu_style = Some(style);
413 self
414 }
415
416 pub fn button_theme(mut self, theme: MenuButtonThemeData) -> Self {
421 self.button_theme = Some(theme);
422 self
423 }
424
425 pub fn elevation(mut self, elevation: f32) -> Self {
433 let style = self.menu_style.get_or_insert_with(MenuStyle::default);
434 style.elevation = Some(elevation);
435 self
436 }
437
438 pub fn anchor_corner(mut self, corner: Corner) -> Self {
440 self.anchor_corner = corner;
441 self
442 }
443
444 pub fn menu_corner(mut self, corner: Corner) -> Self {
446 self.menu_corner = corner;
447 self
448 }
449
450 pub fn default_focus(mut self, focus: FocusState) -> Self {
452 self.default_focus = focus;
453 self
454 }
455
456 pub fn positioning(mut self, positioning: Positioning) -> Self {
458 self.positioning = positioning;
459 self
460 }
461
462 pub fn quick(mut self, quick: bool) -> Self {
464 self.quick = quick;
465 self
466 }
467
468 pub fn has_overflow(mut self, has_overflow: bool) -> Self {
470 self.has_overflow = has_overflow;
471 self
472 }
473
474 pub fn stay_open_on_outside_click(mut self, stay_open: bool) -> Self {
476 self.stay_open_on_outside_click = stay_open;
477 self
478 }
479
480 pub fn stay_open_on_focusout(mut self, stay_open: bool) -> Self {
482 self.stay_open_on_focusout = stay_open;
483 self
484 }
485
486 pub fn skip_restore_focus(mut self, skip: bool) -> Self {
488 self.skip_restore_focus = skip;
489 self
490 }
491
492 pub fn x_offset(mut self, offset: f32) -> Self {
494 self.x_offset = offset;
495 self
496 }
497
498 pub fn y_offset(mut self, offset: f32) -> Self {
500 self.y_offset = offset;
501 self
502 }
503
504 pub fn no_horizontal_flip(mut self, no_flip: bool) -> Self {
506 self.no_horizontal_flip = no_flip;
507 self
508 }
509
510 pub fn no_vertical_flip(mut self, no_flip: bool) -> Self {
512 self.no_vertical_flip = no_flip;
513 self
514 }
515
516 pub fn typeahead_delay(mut self, delay: f32) -> Self {
518 self.typeahead_delay = delay;
519 self
520 }
521
522 pub fn list_tab_index(mut self, index: i32) -> Self {
524 self.list_tab_index = index;
525 self
526 }
527
528 pub fn show(self, ctx: &Context) {
530 if !*self.open {
531 return;
532 }
533
534 let resolved_style = self
535 .menu_style
536 .as_ref()
537 .unwrap_or(&MenuStyle::default())
538 .resolve();
539 let resolved_button = self
540 .button_theme
541 .as_ref()
542 .unwrap_or(&MenuButtonThemeData::default())
543 .resolve();
544
545 let stable_id = egui::Id::new(format!("menu_{}", self.id.value()));
547
548 let frames_since_opened = ctx.data_mut(|d| {
552 let last_open_state = d
553 .get_temp::<bool>(stable_id.with("was_open_last_frame"))
554 .unwrap_or(false);
555 let just_opened = !last_open_state && *self.open;
556 d.insert_temp(stable_id.with("was_open_last_frame"), *self.open);
557
558 let frame_count: u32 = if just_opened {
559 0
560 } else {
561 d.get_temp::<u32>(stable_id.with("open_frame_count"))
562 .unwrap_or(0)
563 .saturating_add(1)
564 };
565 d.insert_temp(stable_id.with("open_frame_count"), frame_count);
566 frame_count
567 });
568 let was_recently_opened = frames_since_opened < 2;
569
570 if frames_since_opened == 0 && !self.skip_restore_focus {
572 ctx.memory_mut(|mem| mem.request_focus(stable_id));
573 }
574
575 let item_height = resolved_button.min_height;
576 let vertical_padding = resolved_style.padding * 2.0;
577 let total_height = self.items.len() as f32 * item_height
578 + self.items.iter().filter(|item| item.divider_after).count() as f32
579 + vertical_padding;
580 let menu_width = resolved_style.max_width;
581
582 let menu_size = Vec2::new(menu_width, total_height);
583
584 let position = if let Some(anchor) = self.anchor_rect {
586 let anchor_point = match self.anchor_corner {
587 Corner::TopLeft => anchor.min,
588 Corner::TopRight => Pos2::new(anchor.max.x, anchor.min.y),
589 Corner::BottomLeft => Pos2::new(anchor.min.x, anchor.max.y),
590 Corner::BottomRight => anchor.max,
591 };
592
593 let menu_offset = match self.menu_corner {
594 Corner::TopLeft => Vec2::ZERO,
595 Corner::TopRight => Vec2::new(-menu_size.x, 0.0),
596 Corner::BottomLeft => Vec2::new(0.0, -menu_size.y),
597 Corner::BottomRight => -menu_size,
598 };
599
600 let base_position = anchor_point + menu_offset;
602 Pos2::new(
603 base_position.x + self.x_offset,
604 base_position.y + self.y_offset + 4.0, )
606 } else {
607 let screen_rect = ctx.screen_rect();
609 screen_rect.center() - menu_size / 2.0
610 };
611
612 let open_ref = self.open;
613 let _id = self.id;
614 let items = self.items;
615 let stay_open_on_outside_click = self.stay_open_on_outside_click;
616 let _stay_open_on_focusout = self.stay_open_on_focusout;
617
618 let _area_response = egui::Area::new(stable_id)
620 .fixed_pos(position)
621 .order(egui::Order::Foreground)
622 .interactable(true)
623 .show(ctx, |ui| {
624 render_menu_content(
625 ui,
626 menu_size,
627 items,
628 &resolved_style,
629 &resolved_button,
630 open_ref,
631 )
632 });
633
634 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
636 *open_ref = false;
637 } else if !stay_open_on_outside_click && !was_recently_opened {
638 if ctx.input(|i| i.pointer.any_click()) {
640 let pointer_pos = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
641 let menu_rect = Rect::from_min_size(position, menu_size);
642
643 let mut inside_area = menu_rect;
645 if let Some(anchor) = self.anchor_rect {
646 inside_area = inside_area.union(anchor);
647 }
648
649 if !inside_area.contains(pointer_pos) {
651 *open_ref = false;
652 }
653 }
654 }
655 }
656}
657
658fn render_menu_content<'a>(
659 ui: &mut Ui,
660 size: Vec2,
661 items: Vec<MenuItem<'a>>,
662 style: &ResolvedMenuStyle,
663 button_theme: &ResolvedMenuButtonTheme,
664 open_ref: &'a mut bool,
665) -> Response {
666 let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
667
668 let outline_variant = get_global_color("outlineVariant");
669
670 let shadow_offset = style.elevation * 2.0;
672 let shadow_rect = rect.expand(shadow_offset);
673 let shadow_alpha = ((style.elevation * 10.0) as u8).min(80);
674 ui.painter().rect_filled(
675 shadow_rect,
676 style.corner_radius,
677 Color32::from_rgba_premultiplied(
678 style.shadow_color.r(),
679 style.shadow_color.g(),
680 style.shadow_color.b(),
681 shadow_alpha,
682 ),
683 );
684
685 ui.painter()
687 .rect_filled(rect, style.corner_radius, style.background_color);
688
689 ui.painter().rect_stroke(
691 rect,
692 style.corner_radius,
693 Stroke::new(1.0, outline_variant),
694 egui::epaint::StrokeKind::Outside,
695 );
696
697 let mut current_y = rect.min.y + style.padding;
698 let mut pending_actions = Vec::new();
699 let mut should_close = false;
700
701 for (index, item) in items.into_iter().enumerate() {
702 let item_rect = Rect::from_min_size(
703 Pos2::new(rect.min.x + 8.0, current_y),
704 Vec2::new(rect.width() - 16.0, button_theme.min_height),
705 );
706
707 let item_response = ui.interact(
708 item_rect,
709 egui::Id::new(format!("menu_item_{}_{}", rect.min.x as i32, index)),
710 Sense::click(),
711 );
712
713 if item.enabled {
715 let overlay_opacity = if item_response.is_pointer_button_down_on() {
716 button_theme.pressed_overlay_opacity
717 } else if item_response.hovered() {
718 button_theme.hover_overlay_opacity
719 } else {
720 0.0
721 };
722
723 if overlay_opacity > 0.0 {
724 let on_surface = button_theme.foreground_color;
725 let overlay_alpha = (overlay_opacity * 255.0) as u8;
726 let hover_color = Color32::from_rgba_premultiplied(
727 on_surface.r(),
728 on_surface.g(),
729 on_surface.b(),
730 overlay_alpha,
731 );
732 ui.painter().rect_filled(item_rect, 4.0, hover_color);
733 }
734 }
735
736 if item_response.clicked() && item.enabled {
738 if let Some(action) = item.action {
739 pending_actions.push(action);
740 should_close = true;
741 }
742 }
743
744 let mut content_x = item_rect.min.x + button_theme.padding_horizontal;
746 let content_y = item_rect.center().y;
747
748 if let Some(_icon) = &item.leading_icon {
750 let half_icon = button_theme.icon_size / 2.0;
751 let icon_rect = Rect::from_min_size(
752 Pos2::new(content_x, content_y - half_icon),
753 Vec2::splat(button_theme.icon_size),
754 );
755
756 let icon_color = if item.enabled {
757 button_theme.icon_color
758 } else {
759 button_theme.disabled_icon_color
760 };
761
762 ui.painter()
763 .circle_filled(icon_rect.center(), half_icon / 3.0 * 2.0, icon_color);
764 content_x += button_theme.icon_size + button_theme.padding_horizontal;
765 }
766
767 let text_color = if item.enabled {
769 button_theme.foreground_color
770 } else {
771 button_theme.disabled_foreground_color
772 };
773
774 let text_pos = Pos2::new(content_x, content_y);
775 ui.painter().text(
776 text_pos,
777 egui::Align2::LEFT_CENTER,
778 &item.text,
779 button_theme.text_font.clone(),
780 text_color,
781 );
782
783 if let Some(_icon) = &item.trailing_icon {
785 let half_icon = button_theme.icon_size / 2.0;
786 let icon_rect = Rect::from_min_size(
787 Pos2::new(
788 item_rect.max.x - button_theme.padding_horizontal - button_theme.icon_size,
789 content_y - half_icon,
790 ),
791 Vec2::splat(button_theme.icon_size),
792 );
793
794 let icon_color = if item.enabled {
795 button_theme.icon_color
796 } else {
797 button_theme.disabled_icon_color
798 };
799
800 ui.painter()
801 .circle_filled(icon_rect.center(), half_icon / 3.0 * 2.0, icon_color);
802 }
803
804 current_y += button_theme.min_height;
805
806 if item.divider_after {
808 let divider_y = current_y;
809 let divider_start = Pos2::new(rect.min.x + 12.0, divider_y);
810 let divider_end = Pos2::new(rect.max.x - 12.0, divider_y);
811
812 ui.painter().line_segment(
813 [divider_start, divider_end],
814 Stroke::new(1.0, outline_variant),
815 );
816 current_y += 1.0;
817 }
818 }
819
820 for action in pending_actions {
822 action();
823 }
824
825 if should_close {
826 *open_ref = false;
827 }
828
829 response
830}
831
832impl<'a> MenuItem<'a> {
833 pub fn new(text: impl Into<String>) -> Self {
843 Self {
844 text: text.into(),
845 leading_icon: None,
846 trailing_icon: None,
847 enabled: true,
848 divider_after: false,
849 action: None,
850 }
851 }
852
853 pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
863 self.leading_icon = Some(icon.into());
864 self
865 }
866
867 pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
877 self.trailing_icon = Some(icon.into());
878 self
879 }
880
881 pub fn enabled(mut self, enabled: bool) -> Self {
891 self.enabled = enabled;
892 self
893 }
894
895 pub fn divider_after(mut self, divider: bool) -> Self {
905 self.divider_after = divider;
906 self
907 }
908
909 pub fn on_click<F>(mut self, f: F) -> Self
920 where
921 F: Fn() + 'a,
922 {
923 self.action = Some(Box::new(f));
924 self
925 }
926}
927
928pub fn menu(id: impl Into<egui::Id>, open: &mut bool) -> MaterialMenu<'_> {
944 MaterialMenu::new(id, open)
945}
946
947pub fn menu_item(text: impl Into<String>) -> MenuItem<'static> {
961 MenuItem::new(text)
962}