Skip to main content

armas_basic/components/
dropdown_menu.rs

1//! Dropdown Menu Component (shadcn/ui style)
2//!
3//! Dropdown menus with keyboard navigation, checkbox items,
4//! radio groups, submenus, and destructive variants.
5//!
6//! Styled to match shadcn/ui dropdown-menu:
7//! - Content: bg-popover text-popover-foreground border rounded-md p-1 shadow-md
8//! - Item: px-2 py-1.5 text-sm rounded-sm gap-2
9//! - Item hover: focus:bg-accent focus:text-accent-foreground
10//! - Destructive: text-destructive focus:bg-destructive/10
11//! - Disabled: opacity-50
12//! - Shortcut: text-muted-foreground text-xs ml-auto tracking-widest
13//! - Separator: bg-border h-px -mx-1 my-1
14//! - Label: px-2 py-1.5 text-sm font-medium
15
16use 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// ============================================================================
23// Submenu State (persisted in egui temp storage)
24// ============================================================================
25
26/// Tracks which submenus are currently open for a menu
27#[derive(Clone, Default)]
28struct SubmenuState {
29    /// Set of open submenu indices
30    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        // Close all others and open this one
51        self.open.clear();
52        self.open.insert(idx);
53    }
54
55    fn close_all(&mut self) {
56        self.open.clear();
57    }
58}
59
60/// Context for rendering menu items (groups common parameters)
61struct 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
69// ============================================================================
70// Constants (matching shadcn Tailwind values)
71// ============================================================================
72
73// Content: min-w-[8rem] = 128px
74const CONTENT_MIN_WIDTH: f32 = 128.0;
75
76// Item: px-2 = 8px, py-1.5 = 6px, text-sm = 14px, gap-2 = 8px, rounded-sm = 2px
77const ITEM_PADDING_X: f32 = 8.0;
78const DEFAULT_ITEM_HEIGHT: f32 = 26.0; // py-1.5 (6px) + text-sm (14px) + py-1.5 (6px) = 26px
79const ITEM_GAP: f32 = 8.0;
80const ITEM_RADIUS: f32 = 2.0;
81// Item text size resolved from theme.typography.base at render-time
82const ITEM_ICON_SIZE: f32 = 16.0; // size-4 = 16px
83
84// Inset: pl-8 = 32px
85const ITEM_INSET_LEFT: f32 = 32.0;
86
87// Checkbox/Radio indicator: left-2 = 8px, size-3.5 = 14px
88const INDICATOR_LEFT: f32 = 8.0;
89const INDICATOR_SIZE: f32 = 14.0;
90
91// Separator: -mx-1 my-1 h-px
92const SEPARATOR_MARGIN_X: f32 = -4.0;
93const SEPARATOR_MARGIN_Y: f32 = 4.0;
94
95// Submenu: chevron size
96const CHEVRON_SIZE: f32 = 16.0;
97
98// ============================================================================
99// Menu Item Types
100// ============================================================================
101
102#[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
137// ============================================================================
138// Menu Builder
139// ============================================================================
140
141/// Builder for constructing menu contents
142pub 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    /// Convert builder into its items (for use by Menubar).
152    pub(crate) fn into_items(self) -> Vec<MenuItemData> {
153        self.items
154    }
155
156    /// Push a pre-built item (for replay by Menubar).
157    pub(crate) fn push_item(&mut self, item: MenuItemData) {
158        self.items.push(item);
159    }
160
161    /// Add a regular menu item
162    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    /// Add a separator line
177    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    /// Add a checkbox item
189    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    /// Add a radio item
204    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    /// Add a submenu
229    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
253// ============================================================================
254// Menu Item Builder
255// ============================================================================
256
257/// Builder for chaining menu item modifiers
258pub 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    /// Set an icon (emoji or symbol)
268    #[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    /// Set a keyboard shortcut display string
277    #[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    /// Set disabled state
286    #[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    /// Set inset (extra left padding for alignment with icon items)
295    #[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    /// Make this a destructive item (red text, for delete actions)
304    #[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
315// ============================================================================
316// Menu Response
317// ============================================================================
318
319/// Response from showing a dropdown menu
320pub struct DropdownMenuResponse {
321    /// The UI response
322    pub response: egui::Response,
323    /// Index of selected/clicked item (if any)
324    pub selected: Option<usize>,
325    /// Whether the user clicked outside the menu
326    pub clicked_outside: bool,
327    /// Checkbox that was toggled: (index, `new_checked_state`)
328    pub checkbox_toggled: Option<(usize, bool)>,
329    /// Radio item that was selected: (`group_name`, value)
330    pub radio_selected: Option<(String, String)>,
331    /// Whether the menu is currently open
332    pub is_open: bool,
333}
334
335impl DropdownMenuResponse {
336    /// Check if a specific item index was selected
337    #[must_use]
338    pub fn is_selected(&self, index: usize) -> bool {
339        self.selected == Some(index)
340    }
341}
342
343/// Internal response used during rendering (before we have the `egui::Response`)
344#[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// ============================================================================
354// Menu Component
355// ============================================================================
356
357/// Dropdown menu component (shadcn/ui Dropdown Menu)
358///
359/// # Example
360///
361/// ```rust,no_run
362/// # use egui::{Ui, Rect};
363/// # fn example(ctx: &egui::Context, anchor: Rect) {
364/// use armas_basic::components::DropdownMenu;
365///
366/// let mut menu = DropdownMenu::new("actions").open(true);
367/// menu.show(ctx, anchor, |builder| {
368///     builder.item("Cut");
369///     builder.item("Copy");
370///     builder.item("Paste");
371///     builder.separator();
372///     builder.item("Delete");
373/// });
374/// # }
375/// ```
376#[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    /// Create a new dropdown menu
387    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) // shadcn: rounded-md border shadow-md
394                .padding(4.0), // p-1 = 4px (shadcn)
395            is_open: None,
396            width: 200.0,
397            item_height: DEFAULT_ITEM_HEIGHT,
398        }
399    }
400
401    /// Set the menu to be open (for external control)
402    #[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    /// Set the menu position
409    #[must_use]
410    pub const fn position(mut self, position: PopoverPosition) -> Self {
411        self.popover = self.popover.position(position);
412        self
413    }
414
415    /// Set the menu width
416    #[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    /// Set the height of each menu item row
423    #[must_use]
424    pub const fn item_height(mut self, height: f32) -> Self {
425        self.item_height = height;
426        self
427    }
428
429    /// Show the menu anchored to a rect
430    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        // Build items using closure
439        let mut builder = MenuBuilder::new();
440        content(&mut builder);
441        let items = builder.items;
442
443        // Load state
444        let (mut is_open, mut selected_index) = self.load_state(ctx);
445        let mut submenu_state = SubmenuState::load(ctx, self.id);
446
447        // Override with external control if set
448        if let Some(external_open) = self.is_open {
449            is_open = external_open;
450        }
451
452        // Handle keyboard navigation (only when open)
453        if is_open {
454            self.handle_keyboard(ctx, &items, &mut is_open, &mut selected_index);
455        }
456
457        // Initialize internal response (without egui::Response)
458        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        // Set popover open state and width
467        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        // Block background scrolling while menu is open
493        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        // Close submenus when an item is selected
506        if inner_response.selected.is_some() {
507            submenu_state.close_all();
508        }
509
510        // Update final open state
511        inner_response.is_open = is_open;
512
513        // Save state
514        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    // ========================================================================
528    // State Management
529    // ========================================================================
530
531    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    // ========================================================================
551    // Keyboard Navigation
552    // ========================================================================
553
554    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                        // Keep menu open for checkbox/radio, close for regular items
570                        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
589// ============================================================================
590// Rendering Functions (free functions to avoid borrow issues)
591// ============================================================================
592
593fn 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    // Extend separator to edges (-mx-1)
681    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
689/// Renders a menu item and returns (`clicked_index`, `is_hovered`)
690fn 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    // Update hover state
715    if is_hovered {
716        *selected_index = Some(idx);
717    }
718
719    // Render background
720    render_item_background(
721        ui,
722        theme,
723        rect,
724        is_selected || item_response.hovered(),
725        destructive,
726        item.disabled,
727    );
728
729    // Render content
730    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, &params);
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            // destructive/10 = 10% opacity
760            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
773/// Parameters for rendering item content
774struct 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    // Left padding
794    x += if params.has_indicator || params.item.inset {
795        ITEM_INSET_LEFT
796    } else {
797        ITEM_PADDING_X
798    };
799
800    // Checkbox/Radio indicator
801    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    // Icon
813    if let Some(icon) = &params.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    // Label
825    ui.painter().text(
826        egui::pos2(x, params.rect.center().y),
827        egui::Align2::LEFT_CENTER,
828        &params.item.label,
829        egui::FontId::proportional(theme.typography.base),
830        text_color,
831    );
832
833    // Shortcut (right-aligned)
834    if let Some(shortcut) = &params.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        // Checkmark
852        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        // Radio dot (filled circle)
861        ui.painter()
862            .circle_filled(indicator_pos, 3.0, theme.foreground());
863    }
864}
865
866/// Parameters for rendering a submenu
867struct SubmenuParams<'a> {
868    idx: usize,
869    item: &'a MenuItemData,
870    sub_items: &'a [MenuItemData],
871}
872
873/// Parameters for `render_submenu` function
874struct 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    // Open submenu when hovering the trigger
899    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    // Render background
905    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    // Render content (label + chevron)
916    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    // Icon
926    if let Some(icon) = &params.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    // Label
938    ui.painter().text(
939        egui::pos2(x, rect.center().y),
940        egui::Align2::LEFT_CENTER,
941        &params.submenu_params.item.label,
942        egui::FontId::proportional(theme.typography.base),
943        text_color,
944    );
945
946    // Chevron (right arrow)
947    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    // Always render the submenu so it can animate closed
957    // Position submenu to the right of the item
958    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    // Propagate submenu responses
979    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// ============================================================================
1078// Item Variant Helper
1079// ============================================================================
1080
1081#[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
1097// ============================================================================
1098// Navigation Helpers
1099// ============================================================================
1100
1101fn 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    // Wrap around
1112    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    // Wrap around
1133    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// ============================================================================
1143// Convenience: MenuItem struct for pre-built items
1144// ============================================================================
1145
1146/// Pre-built dropdown menu item (alternative to builder pattern)
1147#[derive(Clone)]
1148pub struct DropdownMenuItem {
1149    /// Menu item label
1150    pub label: String,
1151    /// Optional icon
1152    pub icon: Option<String>,
1153    /// Optional keyboard shortcut text
1154    pub shortcut: Option<String>,
1155    /// Whether the item is disabled
1156    pub disabled: bool,
1157    /// Whether the item is destructive (e.g., delete action)
1158    pub destructive: bool,
1159}
1160
1161impl DropdownMenuItem {
1162    /// Create a new menu item
1163    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    /// Set an icon
1174    #[must_use]
1175    pub fn icon(mut self, icon: impl Into<String>) -> Self {
1176        self.icon = Some(icon.into());
1177        self
1178    }
1179
1180    /// Set a keyboard shortcut
1181    #[must_use]
1182    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
1183        self.shortcut = Some(shortcut.into());
1184        self
1185    }
1186
1187    /// Set disabled state
1188    #[must_use]
1189    pub const fn disabled(mut self, disabled: bool) -> Self {
1190        self.disabled = disabled;
1191        self
1192    }
1193
1194    /// Make this a destructive item
1195    #[must_use]
1196    pub const fn destructive(mut self) -> Self {
1197        self.destructive = true;
1198        self
1199    }
1200}