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 = 5px, text size from theme.typography.sm, gap-2 = 8px, rounded-sm = 2px
77const ITEM_PADDING_X: f32 = 8.0;
78const DEFAULT_ITEM_HEIGHT: f32 = 22.0; // py-1 (5px) + text-sm (12px) + py-1 (5px) = 22px
79const ITEM_GAP: f32 = 8.0;
80const ITEM_RADIUS: f32 = 2.0;
81// Item text size resolved from theme.typography.sm 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;
363/// # fn example(ui: &mut Ui) {
364/// use armas_basic::components::DropdownMenu;
365///
366/// let resp = DropdownMenu::new("actions")
367///     .show_ui(ui, |ui| ui.button("Actions"), |builder| {
368///         builder.item("Cut");
369///         builder.item("Copy");
370///         builder.item("Paste");
371///         builder.separator();
372///         builder.item("Delete");
373///     });
374/// if let Some(idx) = resp.selected {
375///     // Handle selection
376/// }
377/// # }
378/// ```
379#[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    /// Create a new dropdown menu
390    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) // shadcn: rounded-md border shadow-md
397                .padding(4.0), // p-1 = 4px (shadcn)
398            is_open: None,
399            width: 200.0,
400            item_height: DEFAULT_ITEM_HEIGHT,
401        }
402    }
403
404    /// Set the menu to be open (used internally for submenus).
405    #[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    /// Set the menu position
412    #[must_use]
413    pub const fn position(mut self, position: PopoverPosition) -> Self {
414        self.popover = self.popover.position(position);
415        self
416    }
417
418    /// Set the menu width
419    #[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    /// Set the height of each menu item row
426    #[must_use]
427    pub const fn item_height(mut self, height: f32) -> Self {
428        self.item_height = height;
429        self
430    }
431
432    /// Show with inline trigger widget. Open/close state managed automatically.
433    ///
434    /// The `trigger` closure renders the trigger widget (e.g. a button).
435    /// Clicking it toggles the menu open/closed. The menu auto-closes on
436    /// selection or click-outside.
437    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        // Load open state
444        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        // Render trigger
448        let trigger_resp = trigger(ui);
449
450        // Toggle on click
451        if trigger_resp.clicked() {
452            ui.ctx().data_mut(|d| d.insert_temp(state_id, !is_open));
453        }
454
455        // Show menu using internal method
456        let mut menu = self.open(is_open);
457        let resp = menu.show(ui.ctx(), trigger_resp.rect, content);
458
459        // Auto-close on selection or click-outside
460        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    /// Show the menu at a specific anchor rect with explicit open state.
468    ///
469    /// Use this for context menus where there is no trigger widget — the caller
470    /// controls when the menu is open (e.g. on right-click).
471    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    /// Show the menu anchored to a rect (internal, used by submenus and `show_ui`).
483    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        // Build items using closure
492        let mut builder = MenuBuilder::new();
493        content(&mut builder);
494        let items = builder.items;
495
496        // Load state
497        let (mut is_open, mut selected_index) = self.load_state(ctx);
498        let mut submenu_state = SubmenuState::load(ctx, self.id);
499
500        // Override with external control if set
501        if let Some(external_open) = self.is_open {
502            is_open = external_open;
503        }
504
505        // Handle keyboard navigation (only when open)
506        if is_open {
507            self.handle_keyboard(ctx, &items, &mut is_open, &mut selected_index);
508        }
509
510        // Initialize internal response (without egui::Response)
511        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        // Set popover open state and width
520        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        // Block background scrolling while menu is open
546        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        // Close submenus when an item is selected
559        if inner_response.selected.is_some() {
560            submenu_state.close_all();
561        }
562
563        // Update final open state
564        inner_response.is_open = is_open;
565
566        // Save state
567        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    // ========================================================================
581    // State Management
582    // ========================================================================
583
584    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    // ========================================================================
604    // Keyboard Navigation
605    // ========================================================================
606
607    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                        // Keep menu open for checkbox/radio, close for regular items
623                        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
642// ============================================================================
643// Rendering Functions (free functions to avoid borrow issues)
644// ============================================================================
645
646/// Recursively count selectable (non-separator, non-disabled) items for flat indexing.
647fn 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    // Extend separator to edges (-mx-1)
782    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
790/// Renders a menu item and returns (`clicked_flat_index`, `is_hovered`).
791///
792/// `idx` is the local index within this menu level (for keyboard nav highlighting).
793/// `flat_idx` is the global flat index across all items including submenu children
794/// (for the selection response returned to callers).
795fn 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    // Update hover state (local index for keyboard nav)
821    if is_hovered {
822        *selected_index = Some(idx);
823    }
824
825    // Render background
826    render_item_background(
827        ui,
828        theme,
829        rect,
830        is_selected || item_response.hovered(),
831        destructive,
832        item.disabled,
833    );
834
835    // Render content
836    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, &params);
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            // destructive/10 = 10% opacity
866            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
879/// Parameters for rendering item content
880struct 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    // Left padding
900    x += if params.has_indicator || params.item.inset {
901        ITEM_INSET_LEFT
902    } else {
903        ITEM_PADDING_X
904    };
905
906    // Checkbox/Radio indicator
907    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    // Icon
919    if let Some(icon) = &params.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    // Label
931    ui.painter().text(
932        egui::pos2(x, params.rect.center().y),
933        egui::Align2::LEFT_CENTER,
934        &params.item.label,
935        egui::FontId::proportional(theme.typography.sm),
936        text_color,
937    );
938
939    // Shortcut (right-aligned)
940    if let Some(shortcut) = &params.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        // Checkmark
958        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        // Radio dot (filled circle)
967        ui.painter()
968            .circle_filled(indicator_pos, 3.0, theme.foreground());
969    }
970}
971
972/// Parameters for rendering a submenu
973struct SubmenuParams<'a> {
974    idx: usize,
975    item: &'a MenuItemData,
976    sub_items: &'a [MenuItemData],
977    /// Flat index base — submenu item selections are offset by this value.
978    flat_base: usize,
979}
980
981/// Parameters for `render_submenu` function
982struct 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    // Open submenu when hovering the trigger
1007    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    // Render background
1014    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    // Render content (label + chevron)
1025    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    // Icon
1035    if let Some(icon) = &params.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    // Label
1047    ui.painter().text(
1048        egui::pos2(x, rect.center().y),
1049        egui::Align2::LEFT_CENTER,
1050        &params.submenu_params.item.label,
1051        egui::FontId::proportional(theme.typography.sm),
1052        text_color,
1053    );
1054
1055    // Chevron (right arrow)
1056    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    // Always render the submenu so it can animate closed
1066    // Position submenu to the right of the item
1067    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    // Auto-close submenu when pointer leaves both trigger and submenu content
1088    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(&params.submenu_params.idx);
1094    }
1095
1096    // Propagate submenu responses with flat index offset
1097    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// ============================================================================
1197// Item Variant Helper
1198// ============================================================================
1199
1200#[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
1216// ============================================================================
1217// Navigation Helpers
1218// ============================================================================
1219
1220fn 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    // Wrap around
1231    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    // Wrap around
1252    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// ============================================================================
1262// Convenience: MenuItem struct for pre-built items
1263// ============================================================================
1264
1265/// Pre-built dropdown menu item (alternative to builder pattern)
1266#[derive(Clone)]
1267pub struct DropdownMenuItem {
1268    /// Menu item label
1269    pub label: String,
1270    /// Optional icon
1271    pub icon: Option<String>,
1272    /// Optional keyboard shortcut text
1273    pub shortcut: Option<String>,
1274    /// Whether the item is disabled
1275    pub disabled: bool,
1276    /// Whether the item is destructive (e.g., delete action)
1277    pub destructive: bool,
1278}
1279
1280impl DropdownMenuItem {
1281    /// Create a new menu item
1282    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    /// Set an icon
1293    #[must_use]
1294    pub fn icon(mut self, icon: impl Into<String>) -> Self {
1295        self.icon = Some(icon.into());
1296        self
1297    }
1298
1299    /// Set a keyboard shortcut
1300    #[must_use]
1301    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
1302        self.shortcut = Some(shortcut.into());
1303        self
1304    }
1305
1306    /// Set disabled state
1307    #[must_use]
1308    pub const fn disabled(mut self, disabled: bool) -> Self {
1309        self.disabled = disabled;
1310        self
1311    }
1312
1313    /// Make this a destructive item
1314    #[must_use]
1315    pub const fn destructive(mut self) -> Self {
1316        self.destructive = true;
1317        self
1318    }
1319}