1use crate::components::Kbd;
17use crate::icon;
18use crate::{Popover, PopoverPosition, PopoverStyle};
19use egui::{vec2, Color32, Id, Key, Rect, Sense, Ui};
20use std::collections::HashSet;
21
22#[derive(Clone, Default)]
28struct SubmenuState {
29 open: HashSet<usize>,
31}
32
33impl SubmenuState {
34 fn load(ctx: &egui::Context, menu_id: Id) -> Self {
35 ctx.data_mut(|d| {
36 d.get_temp(menu_id.with("submenu_state"))
37 .unwrap_or_default()
38 })
39 }
40
41 fn save(&self, ctx: &egui::Context, menu_id: Id) {
42 ctx.data_mut(|d| d.insert_temp(menu_id.with("submenu_state"), self.clone()));
43 }
44
45 fn is_open(&self, idx: usize) -> bool {
46 self.open.contains(&idx)
47 }
48
49 fn open_submenu(&mut self, idx: usize) {
50 self.open.clear();
52 self.open.insert(idx);
53 }
54
55 fn close_all(&mut self) {
56 self.open.clear();
57 }
58}
59
60struct MenuRenderContext<'a> {
62 ui: &'a mut Ui,
63 theme: &'a crate::Theme,
64 menu_id: Id,
65 menu_width: f32,
66 item_height: f32,
67}
68
69const CONTENT_MIN_WIDTH: f32 = 128.0;
75
76const ITEM_PADDING_X: f32 = 8.0;
78const DEFAULT_ITEM_HEIGHT: f32 = 22.0; const ITEM_GAP: f32 = 8.0;
80const ITEM_RADIUS: f32 = 2.0;
81const ITEM_ICON_SIZE: f32 = 16.0; const ITEM_INSET_LEFT: f32 = 32.0;
86
87const INDICATOR_LEFT: f32 = 8.0;
89const INDICATOR_SIZE: f32 = 14.0;
90
91const SEPARATOR_MARGIN_X: f32 = -4.0;
93const SEPARATOR_MARGIN_Y: f32 = 4.0;
94
95const CHEVRON_SIZE: f32 = 16.0;
97
98#[derive(Clone)]
103pub(crate) enum MenuItemKind {
104 Item {
105 destructive: bool,
106 },
107 Separator,
108 Checkbox {
109 checked: bool,
110 },
111 Radio {
112 group: String,
113 value: String,
114 selected: bool,
115 },
116 Submenu {
117 items: Vec<MenuItemData>,
118 },
119}
120
121#[derive(Clone)]
122pub(crate) struct MenuItemData {
123 pub(crate) label: String,
124 pub(crate) icon: Option<String>,
125 pub(crate) shortcut: Option<String>,
126 pub(crate) disabled: bool,
127 pub(crate) inset: bool,
128 pub(crate) kind: MenuItemKind,
129}
130
131impl MenuItemData {
132 const fn is_selectable(&self) -> bool {
133 !self.disabled && !matches!(self.kind, MenuItemKind::Separator)
134 }
135}
136
137pub struct MenuBuilder {
143 items: Vec<MenuItemData>,
144}
145
146impl MenuBuilder {
147 pub(crate) const fn new() -> Self {
148 Self { items: Vec::new() }
149 }
150
151 pub(crate) fn into_items(self) -> Vec<MenuItemData> {
153 self.items
154 }
155
156 pub(crate) fn push_item(&mut self, item: MenuItemData) {
158 self.items.push(item);
159 }
160
161 pub fn item(&mut self, label: impl Into<String>) -> MenuItemBuilder<'_> {
163 self.items.push(MenuItemData {
164 label: label.into(),
165 icon: None,
166 shortcut: None,
167 disabled: false,
168 inset: false,
169 kind: MenuItemKind::Item { destructive: false },
170 });
171 MenuItemBuilder {
172 items: &mut self.items,
173 }
174 }
175
176 pub fn separator(&mut self) {
178 self.items.push(MenuItemData {
179 label: String::new(),
180 icon: None,
181 shortcut: None,
182 disabled: false,
183 inset: false,
184 kind: MenuItemKind::Separator,
185 });
186 }
187
188 pub fn checkbox(&mut self, label: impl Into<String>, checked: bool) -> MenuItemBuilder<'_> {
190 self.items.push(MenuItemData {
191 label: label.into(),
192 icon: None,
193 shortcut: None,
194 disabled: false,
195 inset: false,
196 kind: MenuItemKind::Checkbox { checked },
197 });
198 MenuItemBuilder {
199 items: &mut self.items,
200 }
201 }
202
203 pub fn radio(
205 &mut self,
206 label: impl Into<String>,
207 group: impl Into<String>,
208 value: impl Into<String>,
209 selected: bool,
210 ) -> MenuItemBuilder<'_> {
211 self.items.push(MenuItemData {
212 label: label.into(),
213 icon: None,
214 shortcut: None,
215 disabled: false,
216 inset: false,
217 kind: MenuItemKind::Radio {
218 group: group.into(),
219 value: value.into(),
220 selected,
221 },
222 });
223 MenuItemBuilder {
224 items: &mut self.items,
225 }
226 }
227
228 pub fn submenu(
230 &mut self,
231 label: impl Into<String>,
232 content: impl FnOnce(&mut Self),
233 ) -> MenuItemBuilder<'_> {
234 let mut sub_builder = Self::new();
235 content(&mut sub_builder);
236
237 self.items.push(MenuItemData {
238 label: label.into(),
239 icon: None,
240 shortcut: None,
241 disabled: false,
242 inset: false,
243 kind: MenuItemKind::Submenu {
244 items: sub_builder.items,
245 },
246 });
247 MenuItemBuilder {
248 items: &mut self.items,
249 }
250 }
251}
252
253pub struct MenuItemBuilder<'a> {
259 items: &'a mut Vec<MenuItemData>,
260}
261
262impl MenuItemBuilder<'_> {
263 fn current(&mut self) -> Option<&mut MenuItemData> {
264 self.items.last_mut()
265 }
266
267 #[must_use]
269 pub fn icon(mut self, icon: impl Into<String>) -> Self {
270 if let Some(item) = self.current() {
271 item.icon = Some(icon.into());
272 }
273 self
274 }
275
276 #[must_use]
278 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
279 if let Some(item) = self.current() {
280 item.shortcut = Some(shortcut.into());
281 }
282 self
283 }
284
285 #[must_use]
287 pub fn disabled(mut self, disabled: bool) -> Self {
288 if let Some(item) = self.current() {
289 item.disabled = disabled;
290 }
291 self
292 }
293
294 #[must_use]
296 pub fn inset(mut self) -> Self {
297 if let Some(item) = self.current() {
298 item.inset = true;
299 }
300 self
301 }
302
303 #[must_use]
305 pub fn destructive(mut self) -> Self {
306 if let Some(item) = self.current() {
307 if let MenuItemKind::Item { destructive } = &mut item.kind {
308 *destructive = true;
309 }
310 }
311 self
312 }
313}
314
315pub struct DropdownMenuResponse {
321 pub response: egui::Response,
323 pub selected: Option<usize>,
325 pub clicked_outside: bool,
327 pub checkbox_toggled: Option<(usize, bool)>,
329 pub radio_selected: Option<(String, String)>,
331 pub is_open: bool,
333}
334
335impl DropdownMenuResponse {
336 #[must_use]
338 pub fn is_selected(&self, index: usize) -> bool {
339 self.selected == Some(index)
340 }
341}
342
343#[derive(Debug, Clone, Default)]
345struct MenuResponseInner {
346 selected: Option<usize>,
347 clicked_outside: bool,
348 checkbox_toggled: Option<(usize, bool)>,
349 radio_selected: Option<(String, String)>,
350 is_open: bool,
351}
352
353#[derive(Clone)]
380pub struct DropdownMenu {
381 id: Id,
382 popover: Popover,
383 is_open: Option<bool>,
384 width: f32,
385 item_height: f32,
386}
387
388impl DropdownMenu {
389 pub fn new(id: impl Into<Id>) -> Self {
391 let id = id.into();
392 Self {
393 id,
394 popover: Popover::new(id.with("popover"))
395 .position(PopoverPosition::Bottom)
396 .style(PopoverStyle::Default) .padding(4.0), is_open: None,
399 width: 200.0,
400 item_height: DEFAULT_ITEM_HEIGHT,
401 }
402 }
403
404 #[must_use]
406 pub const fn open(mut self, is_open: bool) -> Self {
407 self.is_open = Some(is_open);
408 self
409 }
410
411 #[must_use]
413 pub const fn position(mut self, position: PopoverPosition) -> Self {
414 self.popover = self.popover.position(position);
415 self
416 }
417
418 #[must_use]
420 pub const fn width(mut self, width: f32) -> Self {
421 self.width = width.max(CONTENT_MIN_WIDTH);
422 self
423 }
424
425 #[must_use]
427 pub const fn item_height(mut self, height: f32) -> Self {
428 self.item_height = height;
429 self
430 }
431
432 pub fn show_ui(
438 self,
439 ui: &mut egui::Ui,
440 trigger: impl FnOnce(&mut egui::Ui) -> egui::Response,
441 content: impl FnOnce(&mut MenuBuilder),
442 ) -> DropdownMenuResponse {
443 let state_id = self.id.with("menu_state");
445 let is_open: bool = ui.ctx().data(|d| d.get_temp(state_id).unwrap_or(false));
446
447 let trigger_resp = trigger(ui);
449
450 if trigger_resp.clicked() {
452 ui.ctx().data_mut(|d| d.insert_temp(state_id, !is_open));
453 }
454
455 let mut menu = self.open(is_open);
457 let resp = menu.show(ui.ctx(), trigger_resp.rect, content);
458
459 if resp.selected.is_some() || resp.clicked_outside {
461 ui.ctx().data_mut(|d| d.insert_temp(state_id, false));
462 }
463
464 resp
465 }
466
467 pub fn show_at(
472 mut self,
473 ctx: &egui::Context,
474 anchor_rect: Rect,
475 is_open: bool,
476 content: impl FnOnce(&mut MenuBuilder),
477 ) -> DropdownMenuResponse {
478 self.is_open = Some(is_open);
479 self.show(ctx, anchor_rect, content)
480 }
481
482 pub fn show(
484 &mut self,
485 ctx: &egui::Context,
486 anchor_rect: Rect,
487 content: impl FnOnce(&mut MenuBuilder),
488 ) -> DropdownMenuResponse {
489 let theme = crate::ext::ArmasContextExt::armas_theme(ctx);
490
491 let mut builder = MenuBuilder::new();
493 content(&mut builder);
494 let items = builder.items;
495
496 let (mut is_open, mut selected_index) = self.load_state(ctx);
498 let mut submenu_state = SubmenuState::load(ctx, self.id);
499
500 if let Some(external_open) = self.is_open {
502 is_open = external_open;
503 }
504
505 if is_open {
507 self.handle_keyboard(ctx, &items, &mut is_open, &mut selected_index);
508 }
509
510 let mut inner_response = MenuResponseInner {
512 selected: None,
513 clicked_outside: false,
514 checkbox_toggled: None,
515 radio_selected: None,
516 is_open,
517 };
518
519 self.popover.set_open(is_open);
521 self.popover = self.popover.clone().width(self.width);
522
523 let menu_id = self.id;
524 let menu_width = self.width;
525 let menu_item_height = self.item_height;
526 let popover_response = self.popover.show(ctx, &theme, anchor_rect, |ui| {
527 ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
528
529 let mut ctx = MenuRenderContext {
530 ui,
531 theme: &theme,
532 menu_id,
533 menu_width,
534 item_height: menu_item_height,
535 };
536 render_items(
537 &mut ctx,
538 &items,
539 &mut selected_index,
540 &mut submenu_state,
541 &mut inner_response,
542 );
543 });
544
545 if is_open {
547 ctx.input_mut(|input| {
548 input.smooth_scroll_delta = egui::Vec2::ZERO;
549 });
550 }
551
552 if popover_response.clicked_outside {
553 inner_response.clicked_outside = true;
554 is_open = false;
555 submenu_state.close_all();
556 }
557
558 if inner_response.selected.is_some() {
560 submenu_state.close_all();
561 }
562
563 inner_response.is_open = is_open;
565
566 self.save_state(ctx, is_open, selected_index);
568 submenu_state.save(ctx, self.id);
569
570 DropdownMenuResponse {
571 response: popover_response.response,
572 selected: inner_response.selected,
573 clicked_outside: inner_response.clicked_outside,
574 checkbox_toggled: inner_response.checkbox_toggled,
575 radio_selected: inner_response.radio_selected,
576 is_open: inner_response.is_open,
577 }
578 }
579
580 fn load_state(&self, ctx: &egui::Context) -> (bool, Option<usize>) {
585 let state_id = self.id.with("menu_state");
586 let selected_id = self.id.with("selected_index");
587
588 let is_open = ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(false));
589 let selected_index = ctx.data_mut(|d| d.get_temp(selected_id));
590
591 (is_open, selected_index)
592 }
593
594 fn save_state(&self, ctx: &egui::Context, is_open: bool, selected_index: Option<usize>) {
595 ctx.data_mut(|d| {
596 if self.is_open.is_none() {
597 d.insert_temp(self.id.with("menu_state"), is_open);
598 }
599 d.insert_temp(self.id.with("selected_index"), selected_index);
600 });
601 }
602
603 fn handle_keyboard(
608 &self,
609 ctx: &egui::Context,
610 items: &[MenuItemData],
611 is_open: &mut bool,
612 selected_index: &mut Option<usize>,
613 ) {
614 ctx.input(|i| {
615 if i.key_pressed(Key::ArrowDown) {
616 navigate_down(selected_index, items);
617 } else if i.key_pressed(Key::ArrowUp) {
618 navigate_up(selected_index, items);
619 } else if i.key_pressed(Key::Enter) || i.key_pressed(Key::Space) {
620 if let Some(idx) = *selected_index {
621 if idx < items.len() && items[idx].is_selectable() {
622 if !matches!(
624 items[idx].kind,
625 MenuItemKind::Checkbox { .. }
626 | MenuItemKind::Radio { .. }
627 | MenuItemKind::Submenu { .. }
628 ) {
629 *is_open = false;
630 }
631 *selected_index = None;
632 }
633 }
634 } else if i.key_pressed(Key::Escape) {
635 *is_open = false;
636 *selected_index = None;
637 }
638 });
639 }
640}
641
642fn count_selectable_flat(items: &[MenuItemData]) -> usize {
648 let mut count = 0;
649 for item in items {
650 match &item.kind {
651 MenuItemKind::Separator => {}
652 MenuItemKind::Submenu { items: sub_items } => {
653 count += count_selectable_flat(sub_items);
654 }
655 _ => {
656 count += 1;
657 }
658 }
659 }
660 count
661}
662
663fn render_items(
664 ctx: &mut MenuRenderContext,
665 items: &[MenuItemData],
666 selected_index: &mut Option<usize>,
667 submenu_state: &mut SubmenuState,
668 response: &mut MenuResponseInner,
669) {
670 let mut flat_offset: usize = 0;
671 render_items_inner(
672 ctx,
673 items,
674 selected_index,
675 submenu_state,
676 response,
677 &mut flat_offset,
678 );
679}
680
681fn render_items_inner(
682 ctx: &mut MenuRenderContext,
683 items: &[MenuItemData],
684 selected_index: &mut Option<usize>,
685 submenu_state: &mut SubmenuState,
686 response: &mut MenuResponseInner,
687 flat_offset: &mut usize,
688) {
689 for (idx, item) in items.iter().enumerate() {
690 match &item.kind {
691 MenuItemKind::Separator => {
692 render_separator(ctx.ui, ctx.theme);
693 }
694 MenuItemKind::Item { destructive } => {
695 let fi = *flat_offset;
696 let (result, _) = render_item_with_hover(
697 ctx.ui,
698 ctx.theme,
699 idx,
700 fi,
701 item,
702 *destructive,
703 selected_index,
704 ItemVariant::Normal,
705 ctx.item_height,
706 );
707 if result.is_some() {
708 response.selected = Some(fi);
709 }
710 *flat_offset += 1;
711 }
712 MenuItemKind::Checkbox { checked } => {
713 let fi = *flat_offset;
714 let (result, _) = render_item_with_hover(
715 ctx.ui,
716 ctx.theme,
717 idx,
718 fi,
719 item,
720 false,
721 selected_index,
722 ItemVariant::Checkbox(*checked),
723 ctx.item_height,
724 );
725 if result.is_some() {
726 response.selected = Some(fi);
727 response.checkbox_toggled = Some((fi, !checked));
728 }
729 *flat_offset += 1;
730 }
731 MenuItemKind::Radio {
732 group,
733 value,
734 selected,
735 } => {
736 let fi = *flat_offset;
737 let (result, _) = render_item_with_hover(
738 ctx.ui,
739 ctx.theme,
740 idx,
741 fi,
742 item,
743 false,
744 selected_index,
745 ItemVariant::Radio(*selected),
746 ctx.item_height,
747 );
748 if result.is_some() {
749 response.selected = Some(fi);
750 response.radio_selected = Some((group.clone(), value.clone()));
751 }
752 *flat_offset += 1;
753 }
754 MenuItemKind::Submenu { items: sub_items } => {
755 let sub_base = *flat_offset;
756 *flat_offset += count_selectable_flat(sub_items);
757 let submenu_params = SubmenuParams {
758 idx,
759 item,
760 sub_items,
761 flat_base: sub_base,
762 };
763 let render_params = RenderSubmenuParams {
764 menu_id: ctx.menu_id,
765 menu_width: ctx.menu_width,
766 item_height: ctx.item_height,
767 submenu_params,
768 selected_index,
769 submenu_state,
770 response,
771 };
772 render_submenu(ctx.ui, ctx.theme, render_params);
773 }
774 }
775 }
776}
777
778fn render_separator(ui: &mut Ui, theme: &crate::Theme) {
779 ui.add_space(SEPARATOR_MARGIN_Y);
780 let rect = ui.allocate_space(vec2(ui.available_width(), 1.0)).1;
781 let extended_rect = Rect::from_min_max(
783 rect.min + vec2(SEPARATOR_MARGIN_X, 0.0),
784 rect.max - vec2(SEPARATOR_MARGIN_X, 0.0),
785 );
786 ui.painter().rect_filled(extended_rect, 0.0, theme.border());
787 ui.add_space(SEPARATOR_MARGIN_Y);
788}
789
790fn render_item_with_hover(
796 ui: &mut Ui,
797 theme: &crate::Theme,
798 idx: usize,
799 flat_idx: usize,
800 item: &MenuItemData,
801 destructive: bool,
802 selected_index: &mut Option<usize>,
803 variant: ItemVariant,
804 item_height: f32,
805) -> (Option<usize>, bool) {
806 let is_selected = *selected_index == Some(idx);
807 let has_indicator = matches!(variant, ItemVariant::Checkbox(_) | ItemVariant::Radio(_));
808
809 let (rect, item_response) = ui.allocate_exact_size(
810 vec2(ui.available_width(), item_height),
811 if item.disabled {
812 Sense::hover()
813 } else {
814 Sense::click()
815 },
816 );
817
818 let is_hovered = item_response.hovered() && !item.disabled;
819
820 if is_hovered {
822 *selected_index = Some(idx);
823 }
824
825 render_item_background(
827 ui,
828 theme,
829 rect,
830 is_selected || item_response.hovered(),
831 destructive,
832 item.disabled,
833 );
834
835 let params = ItemContentParams {
837 rect,
838 item,
839 destructive,
840 highlighted: is_selected || item_response.hovered(),
841 has_indicator,
842 variant,
843 };
844 render_item_content(ui, theme, ¶ms);
845
846 let clicked = if item_response.clicked() && !item.disabled {
847 Some(flat_idx)
848 } else {
849 None
850 };
851
852 (clicked, is_hovered)
853}
854
855fn render_item_background(
856 ui: &mut Ui,
857 theme: &crate::Theme,
858 rect: Rect,
859 highlighted: bool,
860 destructive: bool,
861 disabled: bool,
862) {
863 if highlighted && !disabled {
864 let bg_color = if destructive {
865 Color32::from_rgba_unmultiplied(
867 theme.colors.destructive[0],
868 theme.colors.destructive[1],
869 theme.colors.destructive[2],
870 25,
871 )
872 } else {
873 theme.accent()
874 };
875 ui.painter().rect_filled(rect, ITEM_RADIUS, bg_color);
876 }
877}
878
879struct ItemContentParams<'a> {
881 rect: Rect,
882 item: &'a MenuItemData,
883 destructive: bool,
884 highlighted: bool,
885 has_indicator: bool,
886 variant: ItemVariant,
887}
888
889fn render_item_content(ui: &mut Ui, theme: &crate::Theme, params: &ItemContentParams) {
890 let (text_color, icon_color) = get_item_colors(
891 theme,
892 params.destructive,
893 params.highlighted,
894 params.item.disabled,
895 );
896
897 let mut x = params.rect.left();
898
899 x += if params.has_indicator || params.item.inset {
901 ITEM_INSET_LEFT
902 } else {
903 ITEM_PADDING_X
904 };
905
906 if let Some(checked) = params.variant.is_checked() {
908 if checked {
909 render_indicator(
910 ui,
911 theme,
912 params.rect,
913 matches!(params.variant, ItemVariant::Checkbox(_)),
914 );
915 }
916 }
917
918 if let Some(icon) = ¶ms.item.icon {
920 ui.painter().text(
921 egui::pos2(x, params.rect.center().y),
922 egui::Align2::LEFT_CENTER,
923 icon,
924 egui::FontId::proportional(ITEM_ICON_SIZE),
925 icon_color,
926 );
927 x += ITEM_ICON_SIZE + ITEM_GAP;
928 }
929
930 ui.painter().text(
932 egui::pos2(x, params.rect.center().y),
933 egui::Align2::LEFT_CENTER,
934 ¶ms.item.label,
935 egui::FontId::proportional(theme.typography.sm),
936 text_color,
937 );
938
939 if let Some(shortcut) = ¶ms.item.shortcut {
941 let shortcut_rect = Rect::from_min_max(
942 egui::pos2(params.rect.right() - 80.0, params.rect.top()),
943 egui::pos2(params.rect.right() - ITEM_PADDING_X, params.rect.bottom()),
944 );
945 ui.scope_builder(egui::UiBuilder::new().max_rect(shortcut_rect), |ui| {
946 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
947 Kbd::new(shortcut).show(ui);
948 });
949 });
950 }
951}
952
953fn render_indicator(ui: &mut Ui, theme: &crate::Theme, rect: Rect, is_checkbox: bool) {
954 let indicator_pos = rect.left_center() + vec2(INDICATOR_LEFT + INDICATOR_SIZE / 2.0, 0.0);
955
956 if is_checkbox {
957 ui.painter().text(
959 indicator_pos,
960 egui::Align2::CENTER_CENTER,
961 "✓",
962 egui::FontId::proportional(INDICATOR_SIZE),
963 theme.foreground(),
964 );
965 } else {
966 ui.painter()
968 .circle_filled(indicator_pos, 3.0, theme.foreground());
969 }
970}
971
972struct SubmenuParams<'a> {
974 idx: usize,
975 item: &'a MenuItemData,
976 sub_items: &'a [MenuItemData],
977 flat_base: usize,
979}
980
981struct RenderSubmenuParams<'a> {
983 menu_id: Id,
984 menu_width: f32,
985 item_height: f32,
986 submenu_params: SubmenuParams<'a>,
987 selected_index: &'a mut Option<usize>,
988 submenu_state: &'a mut SubmenuState,
989 response: &'a mut MenuResponseInner,
990}
991
992#[allow(clippy::needless_pass_by_value)]
993fn render_submenu(ui: &mut Ui, theme: &crate::Theme, params: RenderSubmenuParams) {
994 let is_selected = *params.selected_index == Some(params.submenu_params.idx);
995 let is_submenu_open = params.submenu_state.is_open(params.submenu_params.idx);
996
997 let (rect, item_response) = ui.allocate_exact_size(
998 vec2(ui.available_width(), params.item_height),
999 if params.submenu_params.item.disabled {
1000 Sense::hover()
1001 } else {
1002 Sense::click()
1003 },
1004 );
1005
1006 let trigger_hovered = item_response.hovered() && !params.submenu_params.item.disabled;
1008 if trigger_hovered {
1009 *params.selected_index = Some(params.submenu_params.idx);
1010 params.submenu_state.open_submenu(params.submenu_params.idx);
1011 }
1012
1013 let highlighted = is_selected || item_response.hovered() || is_submenu_open;
1015 render_item_background(
1016 ui,
1017 theme,
1018 rect,
1019 highlighted,
1020 false,
1021 params.submenu_params.item.disabled,
1022 );
1023
1024 let (text_color, icon_color) = get_item_colors(
1026 theme,
1027 false,
1028 highlighted,
1029 params.submenu_params.item.disabled,
1030 );
1031
1032 let mut x = rect.left() + ITEM_PADDING_X;
1033
1034 if let Some(icon) = ¶ms.submenu_params.item.icon {
1036 ui.painter().text(
1037 egui::pos2(x, rect.center().y),
1038 egui::Align2::LEFT_CENTER,
1039 icon,
1040 egui::FontId::proportional(ITEM_ICON_SIZE),
1041 icon_color,
1042 );
1043 x += ITEM_ICON_SIZE + ITEM_GAP;
1044 }
1045
1046 ui.painter().text(
1048 egui::pos2(x, rect.center().y),
1049 egui::Align2::LEFT_CENTER,
1050 ¶ms.submenu_params.item.label,
1051 egui::FontId::proportional(theme.typography.sm),
1052 text_color,
1053 );
1054
1055 let chevron_rect = Rect::from_center_size(
1057 egui::pos2(
1058 rect.right() - ITEM_PADDING_X - CHEVRON_SIZE / 2.0,
1059 rect.center().y,
1060 ),
1061 vec2(CHEVRON_SIZE, CHEVRON_SIZE),
1062 );
1063 icon::draw_chevron_right(ui.painter(), chevron_rect, icon_color);
1064
1065 let submenu_anchor = Rect::from_min_size(
1068 egui::pos2(rect.right(), rect.top()),
1069 vec2(0.0, rect.height()),
1070 );
1071
1072 let submenu_id = params.menu_id.with(("submenu", params.submenu_params.idx));
1073 let submenu_should_be_open = params.submenu_state.is_open(params.submenu_params.idx)
1074 && !params.submenu_params.item.disabled;
1075
1076 let mut submenu = DropdownMenu::new(submenu_id)
1077 .position(PopoverPosition::Right)
1078 .width(params.menu_width)
1079 .open(submenu_should_be_open);
1080
1081 let sub_response = submenu.show(ui.ctx(), submenu_anchor, |builder| {
1082 for sub_item in params.submenu_params.sub_items {
1083 add_item_to_builder(builder, sub_item);
1084 }
1085 });
1086
1087 if is_submenu_open
1089 && !trigger_hovered
1090 && !sub_response.response.hovered()
1091 && !sub_response.response.contains_pointer()
1092 {
1093 params.submenu_state.open.remove(¶ms.submenu_params.idx);
1094 }
1095
1096 let flat_base = params.submenu_params.flat_base;
1098 if let Some(sel) = sub_response.selected {
1099 params.response.selected = Some(flat_base + sel);
1100 }
1101 if let Some((idx, new_checked)) = sub_response.checkbox_toggled {
1102 params.response.checkbox_toggled = Some((flat_base + idx, new_checked));
1103 }
1104 if sub_response.radio_selected.is_some() {
1105 params.response.radio_selected = sub_response.radio_selected;
1106 }
1107}
1108
1109fn add_item_to_builder(builder: &mut MenuBuilder, item: &MenuItemData) {
1110 match &item.kind {
1111 MenuItemKind::Separator => builder.separator(),
1112 MenuItemKind::Item { destructive } => {
1113 let mut b = builder.item(&item.label);
1114 if let Some(icon) = &item.icon {
1115 b = b.icon(icon);
1116 }
1117 if let Some(shortcut) = &item.shortcut {
1118 b = b.shortcut(shortcut);
1119 }
1120 if item.disabled {
1121 b = b.disabled(true);
1122 }
1123 if item.inset {
1124 b = b.inset();
1125 }
1126 if *destructive {
1127 let _ = b.destructive();
1128 }
1129 }
1130 MenuItemKind::Checkbox { checked } => {
1131 let mut b = builder.checkbox(&item.label, *checked);
1132 if let Some(icon) = &item.icon {
1133 b = b.icon(icon);
1134 }
1135 if item.disabled {
1136 let _ = b.disabled(true);
1137 }
1138 }
1139 MenuItemKind::Radio {
1140 group,
1141 value,
1142 selected,
1143 } => {
1144 let mut b = builder.radio(&item.label, group, value, *selected);
1145 if let Some(icon) = &item.icon {
1146 b = b.icon(icon);
1147 }
1148 if item.disabled {
1149 let _ = b.disabled(true);
1150 }
1151 }
1152 MenuItemKind::Submenu { items } => {
1153 let items_clone = items.clone();
1154 let mut b = builder.submenu(&item.label, |sub| {
1155 for sub_item in &items_clone {
1156 add_item_to_builder(sub, sub_item);
1157 }
1158 });
1159 if let Some(icon) = &item.icon {
1160 b = b.icon(icon);
1161 }
1162 if item.disabled {
1163 let _ = b.disabled(true);
1164 }
1165 }
1166 }
1167}
1168
1169fn get_item_colors(
1170 theme: &crate::Theme,
1171 destructive: bool,
1172 highlighted: bool,
1173 disabled: bool,
1174) -> (Color32, Color32) {
1175 let text_color = if disabled {
1176 theme.foreground().linear_multiply(0.5)
1177 } else if destructive {
1178 theme.destructive()
1179 } else if highlighted {
1180 theme.accent_foreground()
1181 } else {
1182 theme.foreground()
1183 };
1184
1185 let icon_color = if disabled {
1186 theme.muted_foreground().linear_multiply(0.5)
1187 } else if destructive {
1188 theme.destructive()
1189 } else {
1190 theme.muted_foreground()
1191 };
1192
1193 (text_color, icon_color)
1194}
1195
1196#[derive(Clone, Copy)]
1201enum ItemVariant {
1202 Normal,
1203 Checkbox(bool),
1204 Radio(bool),
1205}
1206
1207impl ItemVariant {
1208 const fn is_checked(self) -> Option<bool> {
1209 match self {
1210 Self::Normal => None,
1211 Self::Checkbox(checked) | Self::Radio(checked) => Some(checked),
1212 }
1213 }
1214}
1215
1216fn navigate_down(selected_index: &mut Option<usize>, items: &[MenuItemData]) {
1221 let start_idx = selected_index.map_or(0, |i| i + 1);
1222
1223 for (i, item) in items.iter().enumerate().skip(start_idx) {
1224 if item.is_selectable() {
1225 *selected_index = Some(i);
1226 return;
1227 }
1228 }
1229
1230 for (i, item) in items.iter().enumerate().take(start_idx) {
1232 if item.is_selectable() {
1233 *selected_index = Some(i);
1234 return;
1235 }
1236 }
1237}
1238
1239#[allow(clippy::cast_possible_wrap)]
1240fn navigate_up(selected_index: &mut Option<usize>, items: &[MenuItemData]) {
1241 let start_idx = selected_index.unwrap_or(items.len()) as isize - 1;
1242
1243 for i in (0..=start_idx).rev() {
1244 let idx = i as usize;
1245 if idx < items.len() && items[idx].is_selectable() {
1246 *selected_index = Some(idx);
1247 return;
1248 }
1249 }
1250
1251 for i in (start_idx + 1..items.len() as isize).rev() {
1253 let idx = i as usize;
1254 if items[idx].is_selectable() {
1255 *selected_index = Some(idx);
1256 return;
1257 }
1258 }
1259}
1260
1261#[derive(Clone)]
1267pub struct DropdownMenuItem {
1268 pub label: String,
1270 pub icon: Option<String>,
1272 pub shortcut: Option<String>,
1274 pub disabled: bool,
1276 pub destructive: bool,
1278}
1279
1280impl DropdownMenuItem {
1281 pub fn new(label: impl Into<String>) -> Self {
1283 Self {
1284 label: label.into(),
1285 icon: None,
1286 shortcut: None,
1287 disabled: false,
1288 destructive: false,
1289 }
1290 }
1291
1292 #[must_use]
1294 pub fn icon(mut self, icon: impl Into<String>) -> Self {
1295 self.icon = Some(icon.into());
1296 self
1297 }
1298
1299 #[must_use]
1301 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
1302 self.shortcut = Some(shortcut.into());
1303 self
1304 }
1305
1306 #[must_use]
1308 pub const fn disabled(mut self, disabled: bool) -> Self {
1309 self.disabled = disabled;
1310 self
1311 }
1312
1313 #[must_use]
1315 pub const fn destructive(mut self) -> Self {
1316 self.destructive = true;
1317 self
1318 }
1319}