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 = 26.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)]
377pub struct DropdownMenu {
378 id: Id,
379 popover: Popover,
380 is_open: Option<bool>,
381 width: f32,
382 item_height: f32,
383}
384
385impl DropdownMenu {
386 pub fn new(id: impl Into<Id>) -> Self {
388 let id = id.into();
389 Self {
390 id,
391 popover: Popover::new(id.with("popover"))
392 .position(PopoverPosition::Bottom)
393 .style(PopoverStyle::Default) .padding(4.0), is_open: None,
396 width: 200.0,
397 item_height: DEFAULT_ITEM_HEIGHT,
398 }
399 }
400
401 #[must_use]
403 pub const fn open(mut self, is_open: bool) -> Self {
404 self.is_open = Some(is_open);
405 self
406 }
407
408 #[must_use]
410 pub const fn position(mut self, position: PopoverPosition) -> Self {
411 self.popover = self.popover.position(position);
412 self
413 }
414
415 #[must_use]
417 pub const fn width(mut self, width: f32) -> Self {
418 self.width = width.max(CONTENT_MIN_WIDTH);
419 self
420 }
421
422 #[must_use]
424 pub const fn item_height(mut self, height: f32) -> Self {
425 self.item_height = height;
426 self
427 }
428
429 pub fn show(
431 &mut self,
432 ctx: &egui::Context,
433 anchor_rect: Rect,
434 content: impl FnOnce(&mut MenuBuilder),
435 ) -> DropdownMenuResponse {
436 let theme = crate::ext::ArmasContextExt::armas_theme(ctx);
437
438 let mut builder = MenuBuilder::new();
440 content(&mut builder);
441 let items = builder.items;
442
443 let (mut is_open, mut selected_index) = self.load_state(ctx);
445 let mut submenu_state = SubmenuState::load(ctx, self.id);
446
447 if let Some(external_open) = self.is_open {
449 is_open = external_open;
450 }
451
452 if is_open {
454 self.handle_keyboard(ctx, &items, &mut is_open, &mut selected_index);
455 }
456
457 let mut inner_response = MenuResponseInner {
459 selected: None,
460 clicked_outside: false,
461 checkbox_toggled: None,
462 radio_selected: None,
463 is_open,
464 };
465
466 self.popover.set_open(is_open);
468 self.popover = self.popover.clone().width(self.width);
469
470 let menu_id = self.id;
471 let menu_width = self.width;
472 let menu_item_height = self.item_height;
473 let popover_response = self.popover.show(ctx, &theme, anchor_rect, |ui| {
474 ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
475
476 let mut ctx = MenuRenderContext {
477 ui,
478 theme: &theme,
479 menu_id,
480 menu_width,
481 item_height: menu_item_height,
482 };
483 render_items(
484 &mut ctx,
485 &items,
486 &mut selected_index,
487 &mut submenu_state,
488 &mut inner_response,
489 );
490 });
491
492 if is_open {
494 ctx.input_mut(|input| {
495 input.smooth_scroll_delta = egui::Vec2::ZERO;
496 });
497 }
498
499 if popover_response.clicked_outside {
500 inner_response.clicked_outside = true;
501 is_open = false;
502 submenu_state.close_all();
503 }
504
505 if inner_response.selected.is_some() {
507 submenu_state.close_all();
508 }
509
510 inner_response.is_open = is_open;
512
513 self.save_state(ctx, is_open, selected_index);
515 submenu_state.save(ctx, self.id);
516
517 DropdownMenuResponse {
518 response: popover_response.response,
519 selected: inner_response.selected,
520 clicked_outside: inner_response.clicked_outside,
521 checkbox_toggled: inner_response.checkbox_toggled,
522 radio_selected: inner_response.radio_selected,
523 is_open: inner_response.is_open,
524 }
525 }
526
527 fn load_state(&self, ctx: &egui::Context) -> (bool, Option<usize>) {
532 let state_id = self.id.with("menu_state");
533 let selected_id = self.id.with("selected_index");
534
535 let is_open = ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(false));
536 let selected_index = ctx.data_mut(|d| d.get_temp(selected_id));
537
538 (is_open, selected_index)
539 }
540
541 fn save_state(&self, ctx: &egui::Context, is_open: bool, selected_index: Option<usize>) {
542 ctx.data_mut(|d| {
543 if self.is_open.is_none() {
544 d.insert_temp(self.id.with("menu_state"), is_open);
545 }
546 d.insert_temp(self.id.with("selected_index"), selected_index);
547 });
548 }
549
550 fn handle_keyboard(
555 &self,
556 ctx: &egui::Context,
557 items: &[MenuItemData],
558 is_open: &mut bool,
559 selected_index: &mut Option<usize>,
560 ) {
561 ctx.input(|i| {
562 if i.key_pressed(Key::ArrowDown) {
563 navigate_down(selected_index, items);
564 } else if i.key_pressed(Key::ArrowUp) {
565 navigate_up(selected_index, items);
566 } else if i.key_pressed(Key::Enter) || i.key_pressed(Key::Space) {
567 if let Some(idx) = *selected_index {
568 if idx < items.len() && items[idx].is_selectable() {
569 if !matches!(
571 items[idx].kind,
572 MenuItemKind::Checkbox { .. }
573 | MenuItemKind::Radio { .. }
574 | MenuItemKind::Submenu { .. }
575 ) {
576 *is_open = false;
577 }
578 *selected_index = None;
579 }
580 }
581 } else if i.key_pressed(Key::Escape) {
582 *is_open = false;
583 *selected_index = None;
584 }
585 });
586 }
587}
588
589fn render_items(
594 ctx: &mut MenuRenderContext,
595 items: &[MenuItemData],
596 selected_index: &mut Option<usize>,
597 submenu_state: &mut SubmenuState,
598 response: &mut MenuResponseInner,
599) {
600 for (idx, item) in items.iter().enumerate() {
601 match &item.kind {
602 MenuItemKind::Separator => {
603 render_separator(ctx.ui, ctx.theme);
604 }
605 MenuItemKind::Item { destructive } => {
606 let (result, _) = render_item_with_hover(
607 ctx.ui,
608 ctx.theme,
609 idx,
610 item,
611 *destructive,
612 selected_index,
613 ItemVariant::Normal,
614 ctx.item_height,
615 );
616 if let Some(r) = result {
617 response.selected = Some(r);
618 }
619 }
620 MenuItemKind::Checkbox { checked } => {
621 let (result, _) = render_item_with_hover(
622 ctx.ui,
623 ctx.theme,
624 idx,
625 item,
626 false,
627 selected_index,
628 ItemVariant::Checkbox(*checked),
629 ctx.item_height,
630 );
631 if result.is_some() {
632 response.selected = Some(idx);
633 response.checkbox_toggled = Some((idx, !checked));
634 }
635 }
636 MenuItemKind::Radio {
637 group,
638 value,
639 selected,
640 } => {
641 let (result, _) = render_item_with_hover(
642 ctx.ui,
643 ctx.theme,
644 idx,
645 item,
646 false,
647 selected_index,
648 ItemVariant::Radio(*selected),
649 ctx.item_height,
650 );
651 if result.is_some() {
652 response.selected = Some(idx);
653 response.radio_selected = Some((group.clone(), value.clone()));
654 }
655 }
656 MenuItemKind::Submenu { items: sub_items } => {
657 let submenu_params = SubmenuParams {
658 idx,
659 item,
660 sub_items,
661 };
662 let render_params = RenderSubmenuParams {
663 menu_id: ctx.menu_id,
664 menu_width: ctx.menu_width,
665 item_height: ctx.item_height,
666 submenu_params,
667 selected_index,
668 submenu_state,
669 response,
670 };
671 render_submenu(ctx.ui, ctx.theme, render_params);
672 }
673 }
674 }
675}
676
677fn render_separator(ui: &mut Ui, theme: &crate::Theme) {
678 ui.add_space(SEPARATOR_MARGIN_Y);
679 let rect = ui.allocate_space(vec2(ui.available_width(), 1.0)).1;
680 let extended_rect = Rect::from_min_max(
682 rect.min + vec2(SEPARATOR_MARGIN_X, 0.0),
683 rect.max - vec2(SEPARATOR_MARGIN_X, 0.0),
684 );
685 ui.painter().rect_filled(extended_rect, 0.0, theme.border());
686 ui.add_space(SEPARATOR_MARGIN_Y);
687}
688
689fn render_item_with_hover(
691 ui: &mut Ui,
692 theme: &crate::Theme,
693 idx: usize,
694 item: &MenuItemData,
695 destructive: bool,
696 selected_index: &mut Option<usize>,
697 variant: ItemVariant,
698 item_height: f32,
699) -> (Option<usize>, bool) {
700 let is_selected = *selected_index == Some(idx);
701 let has_indicator = matches!(variant, ItemVariant::Checkbox(_) | ItemVariant::Radio(_));
702
703 let (rect, item_response) = ui.allocate_exact_size(
704 vec2(ui.available_width(), item_height),
705 if item.disabled {
706 Sense::hover()
707 } else {
708 Sense::click()
709 },
710 );
711
712 let is_hovered = item_response.hovered() && !item.disabled;
713
714 if is_hovered {
716 *selected_index = Some(idx);
717 }
718
719 render_item_background(
721 ui,
722 theme,
723 rect,
724 is_selected || item_response.hovered(),
725 destructive,
726 item.disabled,
727 );
728
729 let params = ItemContentParams {
731 rect,
732 item,
733 destructive,
734 highlighted: is_selected || item_response.hovered(),
735 has_indicator,
736 variant,
737 };
738 render_item_content(ui, theme, ¶ms);
739
740 let clicked = if item_response.clicked() && !item.disabled {
741 Some(idx)
742 } else {
743 None
744 };
745
746 (clicked, is_hovered)
747}
748
749fn render_item_background(
750 ui: &mut Ui,
751 theme: &crate::Theme,
752 rect: Rect,
753 highlighted: bool,
754 destructive: bool,
755 disabled: bool,
756) {
757 if highlighted && !disabled {
758 let bg_color = if destructive {
759 Color32::from_rgba_unmultiplied(
761 theme.colors.destructive[0],
762 theme.colors.destructive[1],
763 theme.colors.destructive[2],
764 25,
765 )
766 } else {
767 theme.accent()
768 };
769 ui.painter().rect_filled(rect, ITEM_RADIUS, bg_color);
770 }
771}
772
773struct ItemContentParams<'a> {
775 rect: Rect,
776 item: &'a MenuItemData,
777 destructive: bool,
778 highlighted: bool,
779 has_indicator: bool,
780 variant: ItemVariant,
781}
782
783fn render_item_content(ui: &mut Ui, theme: &crate::Theme, params: &ItemContentParams) {
784 let (text_color, icon_color) = get_item_colors(
785 theme,
786 params.destructive,
787 params.highlighted,
788 params.item.disabled,
789 );
790
791 let mut x = params.rect.left();
792
793 x += if params.has_indicator || params.item.inset {
795 ITEM_INSET_LEFT
796 } else {
797 ITEM_PADDING_X
798 };
799
800 if let Some(checked) = params.variant.is_checked() {
802 if checked {
803 render_indicator(
804 ui,
805 theme,
806 params.rect,
807 matches!(params.variant, ItemVariant::Checkbox(_)),
808 );
809 }
810 }
811
812 if let Some(icon) = ¶ms.item.icon {
814 ui.painter().text(
815 egui::pos2(x, params.rect.center().y),
816 egui::Align2::LEFT_CENTER,
817 icon,
818 egui::FontId::proportional(ITEM_ICON_SIZE),
819 icon_color,
820 );
821 x += ITEM_ICON_SIZE + ITEM_GAP;
822 }
823
824 ui.painter().text(
826 egui::pos2(x, params.rect.center().y),
827 egui::Align2::LEFT_CENTER,
828 ¶ms.item.label,
829 egui::FontId::proportional(theme.typography.base),
830 text_color,
831 );
832
833 if let Some(shortcut) = ¶ms.item.shortcut {
835 let shortcut_rect = Rect::from_min_max(
836 egui::pos2(params.rect.right() - 80.0, params.rect.top()),
837 egui::pos2(params.rect.right() - ITEM_PADDING_X, params.rect.bottom()),
838 );
839 ui.scope_builder(egui::UiBuilder::new().max_rect(shortcut_rect), |ui| {
840 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
841 Kbd::new(shortcut).show(ui);
842 });
843 });
844 }
845}
846
847fn render_indicator(ui: &mut Ui, theme: &crate::Theme, rect: Rect, is_checkbox: bool) {
848 let indicator_pos = rect.left_center() + vec2(INDICATOR_LEFT + INDICATOR_SIZE / 2.0, 0.0);
849
850 if is_checkbox {
851 ui.painter().text(
853 indicator_pos,
854 egui::Align2::CENTER_CENTER,
855 "✓",
856 egui::FontId::proportional(INDICATOR_SIZE),
857 theme.foreground(),
858 );
859 } else {
860 ui.painter()
862 .circle_filled(indicator_pos, 3.0, theme.foreground());
863 }
864}
865
866struct SubmenuParams<'a> {
868 idx: usize,
869 item: &'a MenuItemData,
870 sub_items: &'a [MenuItemData],
871}
872
873struct RenderSubmenuParams<'a> {
875 menu_id: Id,
876 menu_width: f32,
877 item_height: f32,
878 submenu_params: SubmenuParams<'a>,
879 selected_index: &'a mut Option<usize>,
880 submenu_state: &'a mut SubmenuState,
881 response: &'a mut MenuResponseInner,
882}
883
884#[allow(clippy::needless_pass_by_value)]
885fn render_submenu(ui: &mut Ui, theme: &crate::Theme, params: RenderSubmenuParams) {
886 let is_selected = *params.selected_index == Some(params.submenu_params.idx);
887 let is_submenu_open = params.submenu_state.is_open(params.submenu_params.idx);
888
889 let (rect, item_response) = ui.allocate_exact_size(
890 vec2(ui.available_width(), params.item_height),
891 if params.submenu_params.item.disabled {
892 Sense::hover()
893 } else {
894 Sense::click()
895 },
896 );
897
898 if item_response.hovered() && !params.submenu_params.item.disabled {
900 *params.selected_index = Some(params.submenu_params.idx);
901 params.submenu_state.open_submenu(params.submenu_params.idx);
902 }
903
904 let highlighted = is_selected || item_response.hovered() || is_submenu_open;
906 render_item_background(
907 ui,
908 theme,
909 rect,
910 highlighted,
911 false,
912 params.submenu_params.item.disabled,
913 );
914
915 let (text_color, icon_color) = get_item_colors(
917 theme,
918 false,
919 highlighted,
920 params.submenu_params.item.disabled,
921 );
922
923 let mut x = rect.left() + ITEM_PADDING_X;
924
925 if let Some(icon) = ¶ms.submenu_params.item.icon {
927 ui.painter().text(
928 egui::pos2(x, rect.center().y),
929 egui::Align2::LEFT_CENTER,
930 icon,
931 egui::FontId::proportional(ITEM_ICON_SIZE),
932 icon_color,
933 );
934 x += ITEM_ICON_SIZE + ITEM_GAP;
935 }
936
937 ui.painter().text(
939 egui::pos2(x, rect.center().y),
940 egui::Align2::LEFT_CENTER,
941 ¶ms.submenu_params.item.label,
942 egui::FontId::proportional(theme.typography.base),
943 text_color,
944 );
945
946 let chevron_rect = Rect::from_center_size(
948 egui::pos2(
949 rect.right() - ITEM_PADDING_X - CHEVRON_SIZE / 2.0,
950 rect.center().y,
951 ),
952 vec2(CHEVRON_SIZE, CHEVRON_SIZE),
953 );
954 icon::draw_chevron_right(ui.painter(), chevron_rect, icon_color);
955
956 let submenu_anchor = Rect::from_min_size(
959 egui::pos2(rect.right(), rect.top()),
960 vec2(0.0, rect.height()),
961 );
962
963 let submenu_id = params.menu_id.with(("submenu", params.submenu_params.idx));
964 let submenu_should_be_open = params.submenu_state.is_open(params.submenu_params.idx)
965 && !params.submenu_params.item.disabled;
966
967 let mut submenu = DropdownMenu::new(submenu_id)
968 .position(PopoverPosition::Right)
969 .width(params.menu_width)
970 .open(submenu_should_be_open);
971
972 let sub_response = submenu.show(ui.ctx(), submenu_anchor, |builder| {
973 for sub_item in params.submenu_params.sub_items {
974 add_item_to_builder(builder, sub_item);
975 }
976 });
977
978 if sub_response.selected.is_some() {
980 params.response.selected = sub_response.selected;
981 }
982 if sub_response.checkbox_toggled.is_some() {
983 params.response.checkbox_toggled = sub_response.checkbox_toggled;
984 }
985 if sub_response.radio_selected.is_some() {
986 params.response.radio_selected = sub_response.radio_selected;
987 }
988}
989
990fn add_item_to_builder(builder: &mut MenuBuilder, item: &MenuItemData) {
991 match &item.kind {
992 MenuItemKind::Separator => builder.separator(),
993 MenuItemKind::Item { destructive } => {
994 let mut b = builder.item(&item.label);
995 if let Some(icon) = &item.icon {
996 b = b.icon(icon);
997 }
998 if let Some(shortcut) = &item.shortcut {
999 b = b.shortcut(shortcut);
1000 }
1001 if item.disabled {
1002 b = b.disabled(true);
1003 }
1004 if item.inset {
1005 b = b.inset();
1006 }
1007 if *destructive {
1008 let _ = b.destructive();
1009 }
1010 }
1011 MenuItemKind::Checkbox { checked } => {
1012 let mut b = builder.checkbox(&item.label, *checked);
1013 if let Some(icon) = &item.icon {
1014 b = b.icon(icon);
1015 }
1016 if item.disabled {
1017 let _ = b.disabled(true);
1018 }
1019 }
1020 MenuItemKind::Radio {
1021 group,
1022 value,
1023 selected,
1024 } => {
1025 let mut b = builder.radio(&item.label, group, value, *selected);
1026 if let Some(icon) = &item.icon {
1027 b = b.icon(icon);
1028 }
1029 if item.disabled {
1030 let _ = b.disabled(true);
1031 }
1032 }
1033 MenuItemKind::Submenu { items } => {
1034 let items_clone = items.clone();
1035 let mut b = builder.submenu(&item.label, |sub| {
1036 for sub_item in &items_clone {
1037 add_item_to_builder(sub, sub_item);
1038 }
1039 });
1040 if let Some(icon) = &item.icon {
1041 b = b.icon(icon);
1042 }
1043 if item.disabled {
1044 let _ = b.disabled(true);
1045 }
1046 }
1047 }
1048}
1049
1050fn get_item_colors(
1051 theme: &crate::Theme,
1052 destructive: bool,
1053 highlighted: bool,
1054 disabled: bool,
1055) -> (Color32, Color32) {
1056 let text_color = if disabled {
1057 theme.foreground().linear_multiply(0.5)
1058 } else if destructive {
1059 theme.destructive()
1060 } else if highlighted {
1061 theme.accent_foreground()
1062 } else {
1063 theme.foreground()
1064 };
1065
1066 let icon_color = if disabled {
1067 theme.muted_foreground().linear_multiply(0.5)
1068 } else if destructive {
1069 theme.destructive()
1070 } else {
1071 theme.muted_foreground()
1072 };
1073
1074 (text_color, icon_color)
1075}
1076
1077#[derive(Clone, Copy)]
1082enum ItemVariant {
1083 Normal,
1084 Checkbox(bool),
1085 Radio(bool),
1086}
1087
1088impl ItemVariant {
1089 const fn is_checked(self) -> Option<bool> {
1090 match self {
1091 Self::Normal => None,
1092 Self::Checkbox(checked) | Self::Radio(checked) => Some(checked),
1093 }
1094 }
1095}
1096
1097fn navigate_down(selected_index: &mut Option<usize>, items: &[MenuItemData]) {
1102 let start_idx = selected_index.map(|i| i + 1).unwrap_or(0);
1103
1104 for (i, item) in items.iter().enumerate().skip(start_idx) {
1105 if item.is_selectable() {
1106 *selected_index = Some(i);
1107 return;
1108 }
1109 }
1110
1111 for (i, item) in items.iter().enumerate().take(start_idx) {
1113 if item.is_selectable() {
1114 *selected_index = Some(i);
1115 return;
1116 }
1117 }
1118}
1119
1120#[allow(clippy::cast_possible_wrap)]
1121fn navigate_up(selected_index: &mut Option<usize>, items: &[MenuItemData]) {
1122 let start_idx = selected_index.unwrap_or(items.len()) as isize - 1;
1123
1124 for i in (0..=start_idx).rev() {
1125 let idx = i as usize;
1126 if idx < items.len() && items[idx].is_selectable() {
1127 *selected_index = Some(idx);
1128 return;
1129 }
1130 }
1131
1132 for i in (start_idx + 1..items.len() as isize).rev() {
1134 let idx = i as usize;
1135 if items[idx].is_selectable() {
1136 *selected_index = Some(idx);
1137 return;
1138 }
1139 }
1140}
1141
1142#[derive(Clone)]
1148pub struct DropdownMenuItem {
1149 pub label: String,
1151 pub icon: Option<String>,
1153 pub shortcut: Option<String>,
1155 pub disabled: bool,
1157 pub destructive: bool,
1159}
1160
1161impl DropdownMenuItem {
1162 pub fn new(label: impl Into<String>) -> Self {
1164 Self {
1165 label: label.into(),
1166 icon: None,
1167 shortcut: None,
1168 disabled: false,
1169 destructive: false,
1170 }
1171 }
1172
1173 #[must_use]
1175 pub fn icon(mut self, icon: impl Into<String>) -> Self {
1176 self.icon = Some(icon.into());
1177 self
1178 }
1179
1180 #[must_use]
1182 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
1183 self.shortcut = Some(shortcut.into());
1184 self
1185 }
1186
1187 #[must_use]
1189 pub const fn disabled(mut self, disabled: bool) -> Self {
1190 self.disabled = disabled;
1191 self
1192 }
1193
1194 #[must_use]
1196 pub const fn destructive(mut self) -> Self {
1197 self.destructive = true;
1198 self
1199 }
1200}