Skip to main content

egui_material3/
menu.rs

1use crate::get_global_color;
2use eframe::egui::{self, Color32, Context, Id, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
3
4/// Corner position for menu positioning.
5#[derive(Clone, Copy, PartialEq)]
6pub enum Corner {
7    TopLeft,
8    TopRight,
9    BottomLeft,
10    BottomRight,
11}
12
13/// Focus state for keyboard navigation.
14#[derive(Clone, Copy, PartialEq)]
15pub enum FocusState {
16    None,
17    ListRoot,
18    FirstItem,
19}
20
21/// Positioning mode for the menu.
22#[derive(Clone, Copy, PartialEq)]
23pub enum Positioning {
24    Absolute,
25    Fixed,
26    Document,
27    Popover,
28}
29
30/// The visual properties that menus have in common.
31///
32/// Based on Flutter's `MenuStyle`. Controls the appearance of menus
33/// created by `MaterialMenu`. Properties that are `None` use Material
34/// Design 3 defaults.
35///
36/// # M3 Defaults
37/// - `background_color`: `surfaceContainer`
38/// - `shadow_color`: `shadow`
39/// - `elevation`: `3.0`
40/// - `padding`: `8.0` (vertical)
41/// - `corner_radius`: `4.0`
42/// - `min_width`: `112.0`
43/// - `max_width`: `280.0`
44#[derive(Clone, Debug)]
45pub struct MenuStyle {
46    /// The menu's background fill color.
47    pub background_color: Option<Color32>,
48    /// The shadow color of the menu's surface.
49    pub shadow_color: Option<Color32>,
50    /// The surface tint color of the menu.
51    pub surface_tint_color: Option<Color32>,
52    /// The elevation of the menu (affects shadow size).
53    pub elevation: Option<f32>,
54    /// The vertical padding inside the menu.
55    pub padding: Option<f32>,
56    /// The minimum width of the menu.
57    pub min_width: Option<f32>,
58    /// The maximum width of the menu.
59    pub max_width: Option<f32>,
60    /// The corner radius of the menu shape.
61    pub corner_radius: Option<f32>,
62}
63
64impl Default for MenuStyle {
65    fn default() -> Self {
66        Self {
67            background_color: None,
68            shadow_color: None,
69            surface_tint_color: None,
70            elevation: None,
71            padding: None,
72            min_width: None,
73            max_width: None,
74            corner_radius: None,
75        }
76    }
77}
78
79impl MenuStyle {
80    /// Returns a copy of this `MenuStyle` where `None` fields are
81    /// filled in from `other`.
82    pub fn merge(&self, other: &MenuStyle) -> MenuStyle {
83        MenuStyle {
84            background_color: self.background_color.or(other.background_color),
85            shadow_color: self.shadow_color.or(other.shadow_color),
86            surface_tint_color: self.surface_tint_color.or(other.surface_tint_color),
87            elevation: self.elevation.or(other.elevation),
88            padding: self.padding.or(other.padding),
89            min_width: self.min_width.or(other.min_width),
90            max_width: self.max_width.or(other.max_width),
91            corner_radius: self.corner_radius.or(other.corner_radius),
92        }
93    }
94
95    /// Resolve all style values, applying M3 defaults for `None` fields.
96    fn resolve(&self) -> ResolvedMenuStyle {
97        ResolvedMenuStyle {
98            background_color: self
99                .background_color
100                .unwrap_or_else(|| get_global_color("surfaceContainer")),
101            shadow_color: self
102                .shadow_color
103                .unwrap_or_else(|| get_global_color("shadow")),
104            elevation: self.elevation.unwrap_or(3.0),
105            padding: self.padding.unwrap_or(8.0),
106            _min_width: self.min_width.unwrap_or(112.0),
107            max_width: self.max_width.unwrap_or(280.0),
108            corner_radius: self.corner_radius.unwrap_or(4.0),
109        }
110    }
111}
112
113/// Fully resolved menu style with no optional values.
114struct ResolvedMenuStyle {
115    background_color: Color32,
116    shadow_color: Color32,
117    elevation: f32,
118    padding: f32,
119    _min_width: f32,
120    max_width: f32,
121    corner_radius: f32,
122}
123
124/// Theme data for menus created by `MaterialMenu`.
125///
126/// Based on Flutter's `MenuThemeData`. Typically specified as part of
127/// an overall theme. All properties are optional and default to `None`.
128#[derive(Clone, Debug, Default)]
129pub struct MenuThemeData {
130    /// The `MenuStyle` for submenus.
131    pub style: Option<MenuStyle>,
132}
133
134/// Theme data for menu bars.
135///
136/// Based on Flutter's `MenuBarThemeData`. Defines the visual properties
137/// of menu bar widgets themselves, but not their submenus.
138#[derive(Clone, Debug, Default)]
139pub struct MenuBarThemeData {
140    /// The `MenuStyle` for the menu bar.
141    pub style: Option<MenuStyle>,
142}
143
144/// Theme data for menu buttons (menu items).
145///
146/// Based on Flutter's `MenuButtonThemeData` and `_MenuButtonDefaultsM3`.
147/// Controls the appearance of individual menu items.
148///
149/// # M3 Defaults
150/// - `foreground_color`: `onSurface`
151/// - `icon_color`: `onSurfaceVariant`
152/// - `disabled_foreground_color`: `onSurface` at 38% opacity
153/// - `disabled_icon_color`: `onSurface` at 38% opacity
154/// - `hover_overlay_opacity`: `0.08`
155/// - `pressed_overlay_opacity`: `0.10`
156/// - `min_height`: `48.0`
157/// - `icon_size`: `24.0`
158/// - `padding_horizontal`: `12.0`
159#[derive(Clone, Debug)]
160pub struct MenuButtonThemeData {
161    /// Text/foreground color for enabled menu items.
162    pub foreground_color: Option<Color32>,
163    /// Icon color for enabled menu items.
164    pub icon_color: Option<Color32>,
165    /// Text/foreground color for disabled menu items.
166    pub disabled_foreground_color: Option<Color32>,
167    /// Icon color for disabled menu items.
168    pub disabled_icon_color: Option<Color32>,
169    /// Opacity of hover overlay (0.0 to 1.0).
170    pub hover_overlay_opacity: Option<f32>,
171    /// Opacity of pressed overlay (0.0 to 1.0).
172    pub pressed_overlay_opacity: Option<f32>,
173    /// Font for menu item text.
174    pub text_font: Option<egui::FontId>,
175    /// Minimum height of a menu item.
176    pub min_height: Option<f32>,
177    /// Size of leading/trailing icons.
178    pub icon_size: Option<f32>,
179    /// Horizontal padding inside each menu item.
180    pub padding_horizontal: Option<f32>,
181}
182
183impl Default for MenuButtonThemeData {
184    fn default() -> Self {
185        Self {
186            foreground_color: None,
187            icon_color: None,
188            disabled_foreground_color: None,
189            disabled_icon_color: None,
190            hover_overlay_opacity: None,
191            pressed_overlay_opacity: None,
192            text_font: None,
193            min_height: None,
194            icon_size: None,
195            padding_horizontal: None,
196        }
197    }
198}
199
200impl MenuButtonThemeData {
201    /// Resolve all button theme values, applying M3 defaults for `None` fields.
202    fn resolve(&self) -> ResolvedMenuButtonTheme {
203        let on_surface = get_global_color("onSurface");
204        let on_surface_variant = get_global_color("onSurfaceVariant");
205        let disabled_color = Color32::from_rgba_premultiplied(
206            on_surface.r(),
207            on_surface.g(),
208            on_surface.b(),
209            97, // ~38% of 255
210        );
211
212        ResolvedMenuButtonTheme {
213            foreground_color: self.foreground_color.unwrap_or(on_surface),
214            icon_color: self.icon_color.unwrap_or(on_surface_variant),
215            disabled_foreground_color: self.disabled_foreground_color.unwrap_or(disabled_color),
216            disabled_icon_color: self.disabled_icon_color.unwrap_or(disabled_color),
217            hover_overlay_opacity: self.hover_overlay_opacity.unwrap_or(0.08),
218            pressed_overlay_opacity: self.pressed_overlay_opacity.unwrap_or(0.10),
219            text_font: self.text_font.clone().unwrap_or_default(),
220            min_height: self.min_height.unwrap_or(48.0),
221            icon_size: self.icon_size.unwrap_or(24.0),
222            padding_horizontal: self.padding_horizontal.unwrap_or(12.0),
223        }
224    }
225}
226
227/// Fully resolved menu button theme with no optional values.
228struct ResolvedMenuButtonTheme {
229    foreground_color: Color32,
230    icon_color: Color32,
231    disabled_foreground_color: Color32,
232    disabled_icon_color: Color32,
233    hover_overlay_opacity: f32,
234    pressed_overlay_opacity: f32,
235    text_font: egui::FontId,
236    min_height: f32,
237    icon_size: f32,
238    padding_horizontal: f32,
239}
240
241/// Material Design menu component.
242///
243/// Menus display a list of choices on a temporary surface.
244/// They appear when users interact with a button, action, or other control.
245///
246/// # Example
247/// ```rust
248/// # egui::__run_test_ui(|ui| {
249/// let mut menu_open = false;
250///
251/// if ui.button("Open Menu").clicked() {
252///     menu_open = true;
253/// }
254///
255/// let mut menu = MaterialMenu::new(&mut menu_open)
256///     .item("Cut", Some(|| println!("Cut")))
257///     .item("Copy", Some(|| println!("Copy")))
258///     .item("Paste", Some(|| println!("Paste")));
259///
260/// if menu_open {
261///     ui.add(menu);
262/// }
263/// # });
264/// ```
265#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
266pub struct MaterialMenu<'a> {
267    /// Unique identifier for the menu
268    id: Id,
269    /// Reference to the menu open state
270    open: &'a mut bool,
271    /// Rectangle to anchor the menu to
272    anchor_rect: Option<Rect>,
273    /// List of menu items
274    items: Vec<MenuItem<'a>>,
275    /// Corner of the anchor element to align to
276    anchor_corner: Corner,
277    /// Corner of the menu to align with the anchor
278    menu_corner: Corner,
279    /// Initial focus state for keyboard navigation
280    default_focus: FocusState,
281    /// Positioning mode
282    positioning: Positioning,
283    /// Whether the menu uses quick animation
284    quick: bool,
285    /// Whether the menu has overflow scrolling
286    has_overflow: bool,
287    /// Keep menu open when clicking outside
288    stay_open_on_outside_click: bool,
289    /// Keep menu open when focus moves away
290    stay_open_on_focusout: bool,
291    /// Don't restore focus when menu closes
292    skip_restore_focus: bool,
293    /// Horizontal offset from anchor
294    x_offset: f32,
295    /// Vertical offset from anchor
296    y_offset: f32,
297    /// Prevent horizontal flipping when menu would go offscreen
298    no_horizontal_flip: bool,
299    /// Prevent vertical flipping when menu would go offscreen
300    no_vertical_flip: bool,
301    /// Delay for typeahead search in milliseconds
302    typeahead_delay: f32,
303    /// Tab index for keyboard navigation
304    list_tab_index: i32,
305    /// Optional menu style override
306    menu_style: Option<MenuStyle>,
307    /// Optional menu button theme override
308    button_theme: Option<MenuButtonThemeData>,
309}
310
311/// Individual menu item data.
312pub struct MenuItem<'a> {
313    /// Display text for the menu item
314    text: String,
315    /// Optional icon to display at the start of the item
316    leading_icon: Option<String>,
317    /// Optional icon to display at the end of the item
318    trailing_icon: Option<String>,
319    /// Whether the menu item is enabled and interactive
320    enabled: bool,
321    /// Whether to show a divider line after this item
322    divider_after: bool,
323    /// Callback function to execute when the item is clicked
324    action: Option<Box<dyn Fn() + 'a>>,
325}
326
327impl<'a> MaterialMenu<'a> {
328    /// Create a new MaterialMenu instance.
329    ///
330    /// # Arguments
331    /// * `id` - Unique identifier for this menu
332    /// * `open` - Mutable reference to the menu's open state
333    ///
334    /// # Example
335    /// ```rust
336    /// # egui::__run_test_ui(|ui| {
337    /// let mut menu_open = false;
338    /// let menu = MaterialMenu::new("main_menu", &mut menu_open);
339    /// # });
340    /// ```
341    pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
342        Self {
343            id: id.into(),
344            open,
345            anchor_rect: None,
346            items: Vec::new(),
347            // Default values matching Material Web behavior
348            anchor_corner: Corner::BottomLeft,
349            menu_corner: Corner::TopLeft,
350            default_focus: FocusState::None,
351            positioning: Positioning::Absolute,
352            quick: false,
353            has_overflow: false,
354            stay_open_on_outside_click: false,
355            stay_open_on_focusout: false,
356            skip_restore_focus: false,
357            x_offset: 0.0,
358            y_offset: 0.0,
359            no_horizontal_flip: false,
360            no_vertical_flip: false,
361            typeahead_delay: 200.0,
362            list_tab_index: -1,
363            menu_style: None,
364            button_theme: None,
365        }
366    }
367
368    /// Set the anchor rectangle for the menu.
369    ///
370    /// The menu will be positioned relative to this rectangle.
371    ///
372    /// # Arguments
373    /// * `rect` - The rectangle to anchor the menu to
374    ///
375    /// # Example
376    /// ```rust
377    /// # egui::__run_test_ui(|ui| {
378    /// let mut menu_open = false;
379    /// let button_rect = ui.available_rect_before_wrap();
380    /// let menu = MaterialMenu::new("menu", &mut menu_open)
381    ///     .anchor_rect(button_rect);
382    /// # });
383    /// ```
384    pub fn anchor_rect(mut self, rect: Rect) -> Self {
385        self.anchor_rect = Some(rect);
386        self
387    }
388
389    /// Add an item to the menu.
390    ///
391    /// # Arguments
392    /// * `item` - The menu item to add
393    ///
394    /// # Example
395    /// ```rust
396    /// # egui::__run_test_ui(|ui| {
397    /// let mut menu_open = false;
398    /// let item = MenuItem::new("Cut").action(|| println!("Cut"));
399    /// let menu = MaterialMenu::new("menu", &mut menu_open).item(item);
400    /// # });
401    /// ```
402    pub fn item(mut self, item: MenuItem<'a>) -> Self {
403        self.items.push(item);
404        self
405    }
406
407    /// Set the menu style, overriding Material Design 3 defaults.
408    ///
409    /// # Arguments
410    /// * `style` - The `MenuStyle` to apply
411    pub fn style(mut self, style: MenuStyle) -> Self {
412        self.menu_style = Some(style);
413        self
414    }
415
416    /// Set the button theme, overriding Material Design 3 defaults.
417    ///
418    /// # Arguments
419    /// * `theme` - The `MenuButtonThemeData` to apply
420    pub fn button_theme(mut self, theme: MenuButtonThemeData) -> Self {
421        self.button_theme = Some(theme);
422        self
423    }
424
425    /// Set the elevation (shadow) of the menu.
426    ///
427    /// This is a shorthand for setting `MenuStyle.elevation`.
428    /// Material Design defines typical elevation levels from 0 to 12.
429    ///
430    /// # Arguments
431    /// * `elevation` - Elevation level
432    pub fn elevation(mut self, elevation: f32) -> Self {
433        let style = self.menu_style.get_or_insert_with(MenuStyle::default);
434        style.elevation = Some(elevation);
435        self
436    }
437
438    /// Set the anchor corner for the menu.
439    pub fn anchor_corner(mut self, corner: Corner) -> Self {
440        self.anchor_corner = corner;
441        self
442    }
443
444    /// Set the menu corner for positioning.
445    pub fn menu_corner(mut self, corner: Corner) -> Self {
446        self.menu_corner = corner;
447        self
448    }
449
450    /// Set the default focus state for the menu.
451    pub fn default_focus(mut self, focus: FocusState) -> Self {
452        self.default_focus = focus;
453        self
454    }
455
456    /// Set the positioning mode for the menu.
457    pub fn positioning(mut self, positioning: Positioning) -> Self {
458        self.positioning = positioning;
459        self
460    }
461
462    /// Enable or disable quick animation for the menu.
463    pub fn quick(mut self, quick: bool) -> Self {
464        self.quick = quick;
465        self
466    }
467
468    /// Enable or disable overflow scrolling for the menu.
469    pub fn has_overflow(mut self, has_overflow: bool) -> Self {
470        self.has_overflow = has_overflow;
471        self
472    }
473
474    /// Keep the menu open when clicking outside of it.
475    pub fn stay_open_on_outside_click(mut self, stay_open: bool) -> Self {
476        self.stay_open_on_outside_click = stay_open;
477        self
478    }
479
480    /// Keep the menu open when focus moves away from it.
481    pub fn stay_open_on_focusout(mut self, stay_open: bool) -> Self {
482        self.stay_open_on_focusout = stay_open;
483        self
484    }
485
486    /// Skip restoring focus when the menu closes.
487    pub fn skip_restore_focus(mut self, skip: bool) -> Self {
488        self.skip_restore_focus = skip;
489        self
490    }
491
492    /// Set the horizontal offset for the menu.
493    pub fn x_offset(mut self, offset: f32) -> Self {
494        self.x_offset = offset;
495        self
496    }
497
498    /// Set the vertical offset for the menu.
499    pub fn y_offset(mut self, offset: f32) -> Self {
500        self.y_offset = offset;
501        self
502    }
503
504    /// Prevent horizontal flipping when the menu would go offscreen.
505    pub fn no_horizontal_flip(mut self, no_flip: bool) -> Self {
506        self.no_horizontal_flip = no_flip;
507        self
508    }
509
510    /// Prevent vertical flipping when the menu would go offscreen.
511    pub fn no_vertical_flip(mut self, no_flip: bool) -> Self {
512        self.no_vertical_flip = no_flip;
513        self
514    }
515
516    /// Set the typeahead delay for the menu.
517    pub fn typeahead_delay(mut self, delay: f32) -> Self {
518        self.typeahead_delay = delay;
519        self
520    }
521
522    /// Set the tab index for keyboard navigation.
523    pub fn list_tab_index(mut self, index: i32) -> Self {
524        self.list_tab_index = index;
525        self
526    }
527
528    /// Show the menu in the given context.
529    pub fn show(self, ctx: &Context) {
530        if !*self.open {
531            return;
532        }
533
534        let resolved_style = self
535            .menu_style
536            .as_ref()
537            .unwrap_or(&MenuStyle::default())
538            .resolve();
539        let resolved_button = self
540            .button_theme
541            .as_ref()
542            .unwrap_or(&MenuButtonThemeData::default())
543            .resolve();
544
545        // Use a stable ID for the menu
546        let stable_id = egui::Id::new(format!("menu_{}", self.id.value()));
547
548        // Track how many frames the menu has been open. A mouse click can span
549        // two frames (press on frame N, release on frame N+1), so we need to
550        // suppress outside-click detection for at least 2 frames after opening.
551        let frames_since_opened = ctx.data_mut(|d| {
552            let last_open_state = d
553                .get_temp::<bool>(stable_id.with("was_open_last_frame"))
554                .unwrap_or(false);
555            let just_opened = !last_open_state && *self.open;
556            d.insert_temp(stable_id.with("was_open_last_frame"), *self.open);
557
558            let frame_count: u32 = if just_opened {
559                0
560            } else {
561                d.get_temp::<u32>(stable_id.with("open_frame_count"))
562                    .unwrap_or(0)
563                    .saturating_add(1)
564            };
565            d.insert_temp(stable_id.with("open_frame_count"), frame_count);
566            frame_count
567        });
568        let was_recently_opened = frames_since_opened < 2;
569
570        // Request focus when menu opens
571        if frames_since_opened == 0 && !self.skip_restore_focus {
572            ctx.memory_mut(|mem| mem.request_focus(stable_id));
573        }
574
575        let item_height = resolved_button.min_height;
576        let vertical_padding = resolved_style.padding * 2.0;
577        let total_height = self.items.len() as f32 * item_height
578            + self.items.iter().filter(|item| item.divider_after).count() as f32
579            + vertical_padding;
580        let menu_width = resolved_style.max_width;
581
582        let menu_size = Vec2::new(menu_width, total_height);
583
584        // Determine position based on anchor corner and menu corner
585        let position = if let Some(anchor) = self.anchor_rect {
586            let anchor_point = match self.anchor_corner {
587                Corner::TopLeft => anchor.min,
588                Corner::TopRight => Pos2::new(anchor.max.x, anchor.min.y),
589                Corner::BottomLeft => Pos2::new(anchor.min.x, anchor.max.y),
590                Corner::BottomRight => anchor.max,
591            };
592
593            let menu_offset = match self.menu_corner {
594                Corner::TopLeft => Vec2::ZERO,
595                Corner::TopRight => Vec2::new(-menu_size.x, 0.0),
596                Corner::BottomLeft => Vec2::new(0.0, -menu_size.y),
597                Corner::BottomRight => -menu_size,
598            };
599
600            // Apply the corner positioning and offsets
601            let base_position = anchor_point + menu_offset;
602            Pos2::new(
603                base_position.x + self.x_offset,
604                base_position.y + self.y_offset + 4.0, // 4px spacing from anchor
605            )
606        } else {
607            // Center on screen
608            let screen_rect = ctx.screen_rect();
609            screen_rect.center() - menu_size / 2.0
610        };
611
612        let open_ref = self.open;
613        let _id = self.id;
614        let items = self.items;
615        let stay_open_on_outside_click = self.stay_open_on_outside_click;
616        let _stay_open_on_focusout = self.stay_open_on_focusout;
617
618        // Create a popup window for the menu with a stable layer and unique ID
619        let _area_response = egui::Area::new(stable_id)
620            .fixed_pos(position)
621            .order(egui::Order::Foreground)
622            .interactable(true)
623            .show(ctx, |ui| {
624                render_menu_content(
625                    ui,
626                    menu_size,
627                    items,
628                    &resolved_style,
629                    &resolved_button,
630                    open_ref,
631                )
632            });
633
634        // Handle closing behavior based on settings
635        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
636            *open_ref = false;
637        } else if !stay_open_on_outside_click && !was_recently_opened {
638            // Only handle outside clicks if not staying open and not just opened
639            if ctx.input(|i| i.pointer.any_click()) {
640                let pointer_pos = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
641                let menu_rect = Rect::from_min_size(position, menu_size);
642
643                // Include anchor rect in the "inside" area to prevent closing when clicking trigger
644                let mut inside_area = menu_rect;
645                if let Some(anchor) = self.anchor_rect {
646                    inside_area = inside_area.union(anchor);
647                }
648
649                // Only close if click was outside both menu and anchor areas
650                if !inside_area.contains(pointer_pos) {
651                    *open_ref = false;
652                }
653            }
654        }
655    }
656}
657
658fn render_menu_content<'a>(
659    ui: &mut Ui,
660    size: Vec2,
661    items: Vec<MenuItem<'a>>,
662    style: &ResolvedMenuStyle,
663    button_theme: &ResolvedMenuButtonTheme,
664    open_ref: &'a mut bool,
665) -> Response {
666    let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
667
668    let outline_variant = get_global_color("outlineVariant");
669
670    // Draw shadow for elevation
671    let shadow_offset = style.elevation * 2.0;
672    let shadow_rect = rect.expand(shadow_offset);
673    let shadow_alpha = ((style.elevation * 10.0) as u8).min(80);
674    ui.painter().rect_filled(
675        shadow_rect,
676        style.corner_radius,
677        Color32::from_rgba_premultiplied(
678            style.shadow_color.r(),
679            style.shadow_color.g(),
680            style.shadow_color.b(),
681            shadow_alpha,
682        ),
683    );
684
685    // Draw menu background
686    ui.painter()
687        .rect_filled(rect, style.corner_radius, style.background_color);
688
689    // Draw menu border
690    ui.painter().rect_stroke(
691        rect,
692        style.corner_radius,
693        Stroke::new(1.0, outline_variant),
694        egui::epaint::StrokeKind::Outside,
695    );
696
697    let mut current_y = rect.min.y + style.padding;
698    let mut pending_actions = Vec::new();
699    let mut should_close = false;
700
701    for (index, item) in items.into_iter().enumerate() {
702        let item_rect = Rect::from_min_size(
703            Pos2::new(rect.min.x + 8.0, current_y),
704            Vec2::new(rect.width() - 16.0, button_theme.min_height),
705        );
706
707        let item_response = ui.interact(
708            item_rect,
709            egui::Id::new(format!("menu_item_{}_{}", rect.min.x as i32, index)),
710            Sense::click(),
711        );
712
713        // Draw item background on hover/press
714        if item.enabled {
715            let overlay_opacity = if item_response.is_pointer_button_down_on() {
716                button_theme.pressed_overlay_opacity
717            } else if item_response.hovered() {
718                button_theme.hover_overlay_opacity
719            } else {
720                0.0
721            };
722
723            if overlay_opacity > 0.0 {
724                let on_surface = button_theme.foreground_color;
725                let overlay_alpha = (overlay_opacity * 255.0) as u8;
726                let hover_color = Color32::from_rgba_premultiplied(
727                    on_surface.r(),
728                    on_surface.g(),
729                    on_surface.b(),
730                    overlay_alpha,
731                );
732                ui.painter().rect_filled(item_rect, 4.0, hover_color);
733            }
734        }
735
736        // Handle click
737        if item_response.clicked() && item.enabled {
738            if let Some(action) = item.action {
739                pending_actions.push(action);
740                should_close = true;
741            }
742        }
743
744        // Layout item content
745        let mut content_x = item_rect.min.x + button_theme.padding_horizontal;
746        let content_y = item_rect.center().y;
747
748        // Draw leading icon
749        if let Some(_icon) = &item.leading_icon {
750            let half_icon = button_theme.icon_size / 2.0;
751            let icon_rect = Rect::from_min_size(
752                Pos2::new(content_x, content_y - half_icon),
753                Vec2::splat(button_theme.icon_size),
754            );
755
756            let icon_color = if item.enabled {
757                button_theme.icon_color
758            } else {
759                button_theme.disabled_icon_color
760            };
761
762            ui.painter()
763                .circle_filled(icon_rect.center(), half_icon / 3.0 * 2.0, icon_color);
764            content_x += button_theme.icon_size + button_theme.padding_horizontal;
765        }
766
767        // Draw text
768        let text_color = if item.enabled {
769            button_theme.foreground_color
770        } else {
771            button_theme.disabled_foreground_color
772        };
773
774        let text_pos = Pos2::new(content_x, content_y);
775        ui.painter().text(
776            text_pos,
777            egui::Align2::LEFT_CENTER,
778            &item.text,
779            button_theme.text_font.clone(),
780            text_color,
781        );
782
783        // Draw trailing icon
784        if let Some(_icon) = &item.trailing_icon {
785            let half_icon = button_theme.icon_size / 2.0;
786            let icon_rect = Rect::from_min_size(
787                Pos2::new(
788                    item_rect.max.x - button_theme.padding_horizontal - button_theme.icon_size,
789                    content_y - half_icon,
790                ),
791                Vec2::splat(button_theme.icon_size),
792            );
793
794            let icon_color = if item.enabled {
795                button_theme.icon_color
796            } else {
797                button_theme.disabled_icon_color
798            };
799
800            ui.painter()
801                .circle_filled(icon_rect.center(), half_icon / 3.0 * 2.0, icon_color);
802        }
803
804        current_y += button_theme.min_height;
805
806        // Draw divider
807        if item.divider_after {
808            let divider_y = current_y;
809            let divider_start = Pos2::new(rect.min.x + 12.0, divider_y);
810            let divider_end = Pos2::new(rect.max.x - 12.0, divider_y);
811
812            ui.painter().line_segment(
813                [divider_start, divider_end],
814                Stroke::new(1.0, outline_variant),
815            );
816            current_y += 1.0;
817        }
818    }
819
820    // Execute pending actions
821    for action in pending_actions {
822        action();
823    }
824
825    if should_close {
826        *open_ref = false;
827    }
828
829    response
830}
831
832impl<'a> MenuItem<'a> {
833    /// Create a new menu item.
834    ///
835    /// # Arguments
836    /// * `text` - Display text for the menu item
837    ///
838    /// # Example
839    /// ```rust
840    /// let item = MenuItem::new("Copy");
841    /// ```
842    pub fn new(text: impl Into<String>) -> Self {
843        Self {
844            text: text.into(),
845            leading_icon: None,
846            trailing_icon: None,
847            enabled: true,
848            divider_after: false,
849            action: None,
850        }
851    }
852
853    /// Set the leading icon for the menu item.
854    ///
855    /// # Arguments
856    /// * `icon` - Icon identifier (e.g., "copy", "cut", "paste")
857    ///
858    /// # Example
859    /// ```rust
860    /// let item = MenuItem::new("Copy").leading_icon("content_copy");
861    /// ```
862    pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
863        self.leading_icon = Some(icon.into());
864        self
865    }
866
867    /// Set the trailing icon for the menu item.
868    ///
869    /// # Arguments
870    /// * `icon` - Icon identifier (e.g., "keyboard_arrow_right", "check")
871    ///
872    /// # Example
873    /// ```rust
874    /// let item = MenuItem::new("Save").trailing_icon("keyboard_arrow_right");
875    /// ```
876    pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
877        self.trailing_icon = Some(icon.into());
878        self
879    }
880
881    /// Enable or disable the menu item.
882    ///
883    /// # Arguments
884    /// * `enabled` - Whether the menu item should be interactive
885    ///
886    /// # Example
887    /// ```rust
888    /// let item = MenuItem::new("Paste").enabled(false); // Disabled item
889    /// ```
890    pub fn enabled(mut self, enabled: bool) -> Self {
891        self.enabled = enabled;
892        self
893    }
894
895    /// Add a divider after the menu item.
896    ///
897    /// # Arguments
898    /// * `divider` - Whether to show a divider line after this item
899    ///
900    /// # Example
901    /// ```rust
902    /// let item = MenuItem::new("Copy").divider_after(true); // Show divider after this item
903    /// ```
904    pub fn divider_after(mut self, divider: bool) -> Self {
905        self.divider_after = divider;
906        self
907    }
908
909    /// Set the action to be performed when the menu item is clicked.
910    ///
911    /// # Arguments
912    /// * `f` - Closure to execute when the item is clicked
913    ///
914    /// # Example
915    /// ```rust
916    /// let item = MenuItem::new("Delete")
917    ///     .on_click(|| println!("Item deleted"));
918    /// ```
919    pub fn on_click<F>(mut self, f: F) -> Self
920    where
921        F: Fn() + 'a,
922    {
923        self.action = Some(Box::new(f));
924        self
925    }
926}
927
928/// Convenience function to create a new menu instance.
929///
930/// Shorthand for `MaterialMenu::new()`.
931///
932/// # Arguments
933/// * `id` - Unique identifier for this menu
934/// * `open` - Mutable reference to the menu's open state
935///
936/// # Example
937/// ```rust
938/// # egui::__run_test_ui(|ui| {
939/// let mut menu_open = false;
940/// let menu = menu("context_menu", &mut menu_open);
941/// # });
942/// ```
943pub fn menu(id: impl Into<egui::Id>, open: &mut bool) -> MaterialMenu<'_> {
944    MaterialMenu::new(id, open)
945}
946
947/// Convenience function to create a new menu item.
948///
949/// Shorthand for `MenuItem::new()`.
950///
951/// # Arguments
952/// * `text` - Display text for the menu item
953///
954/// # Example
955/// ```rust
956/// let item = menu_item("Copy")
957///     .leading_icon("content_copy")
958///     .on_click(|| println!("Copy action"));
959/// ```
960pub fn menu_item(text: impl Into<String>) -> MenuItem<'static> {
961    MenuItem::new(text)
962}