egui_material3/
menu.rs

1use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Context, Id};
2use crate::get_global_color;
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/// Material Design menu component.
31///
32/// Menus display a list of choices on a temporary surface.
33/// They appear when users interact with a button, action, or other control.
34///
35/// # Example
36/// ```rust
37/// # egui::__run_test_ui(|ui| {
38/// let mut menu_open = false;
39/// 
40/// if ui.button("Open Menu").clicked() {
41///     menu_open = true;
42/// }
43///
44/// let mut menu = MaterialMenu::new(&mut menu_open)
45///     .item("Cut", Some(|| println!("Cut")))
46///     .item("Copy", Some(|| println!("Copy")))
47///     .item("Paste", Some(|| println!("Paste")));
48///
49/// if menu_open {
50///     ui.add(menu);
51/// }
52/// # });
53/// ```
54#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
55pub struct MaterialMenu<'a> {
56    /// Unique identifier for the menu
57    id: Id,
58    /// Reference to the menu open state
59    open: &'a mut bool,
60    /// Rectangle to anchor the menu to
61    anchor_rect: Option<Rect>,
62    /// List of menu items
63    items: Vec<MenuItem<'a>>,
64    /// Material Design elevation level (0-24)
65    elevation: u8,
66    /// Corner of the anchor element to align to
67    anchor_corner: Corner,
68    /// Corner of the menu to align with the anchor
69    menu_corner: Corner,
70    /// Initial focus state for keyboard navigation
71    default_focus: FocusState,
72    /// Positioning mode
73    positioning: Positioning,
74    /// Whether the menu uses quick animation
75    quick: bool,
76    /// Whether the menu has overflow scrolling
77    has_overflow: bool,
78    /// Keep menu open when clicking outside
79    stay_open_on_outside_click: bool,
80    /// Keep menu open when focus moves away
81    stay_open_on_focusout: bool,
82    /// Don't restore focus when menu closes
83    skip_restore_focus: bool,
84    /// Horizontal offset from anchor
85    x_offset: f32,
86    /// Vertical offset from anchor
87    y_offset: f32,
88    /// Prevent horizontal flipping when menu would go offscreen
89    no_horizontal_flip: bool,
90    /// Prevent vertical flipping when menu would go offscreen
91    no_vertical_flip: bool,
92    /// Delay for typeahead search in milliseconds
93    typeahead_delay: f32,
94    /// Tab index for keyboard navigation
95    list_tab_index: i32,
96}
97
98/// Individual menu item data.
99pub struct MenuItem<'a> {
100    /// Display text for the menu item
101    text: String,
102    /// Optional icon to display at the start of the item
103    leading_icon: Option<String>,
104    /// Optional icon to display at the end of the item
105    trailing_icon: Option<String>,
106    /// Whether the menu item is enabled and interactive
107    enabled: bool,
108    /// Whether to show a divider line after this item
109    divider_after: bool,
110    /// Callback function to execute when the item is clicked
111    action: Option<Box<dyn Fn() + 'a>>,
112}
113
114impl<'a> MaterialMenu<'a> {
115    /// Create a new MaterialMenu instance.
116    ///
117    /// # Arguments
118    /// * `id` - Unique identifier for this menu
119    /// * `open` - Mutable reference to the menu's open state
120    ///
121    /// # Example
122    /// ```rust
123    /// # egui::__run_test_ui(|ui| {
124    /// let mut menu_open = false;
125    /// let menu = MaterialMenu::new("main_menu", &mut menu_open);
126    /// # });
127    /// ```
128    pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
129        Self {
130            id: id.into(),
131            open,
132            anchor_rect: None,
133            items: Vec::new(),
134            elevation: 3,
135            // Default values matching Material Web behavior
136            anchor_corner: Corner::BottomLeft,
137            menu_corner: Corner::TopLeft,
138            default_focus: FocusState::None,
139            positioning: Positioning::Absolute,
140            quick: false,
141            has_overflow: false,
142            stay_open_on_outside_click: false,
143            stay_open_on_focusout: false,
144            skip_restore_focus: false,
145            x_offset: 0.0,
146            y_offset: 0.0,
147            no_horizontal_flip: false,
148            no_vertical_flip: false,
149            typeahead_delay: 200.0,
150            list_tab_index: -1,
151        }
152    }
153
154    /// Set the anchor rectangle for the menu.
155    ///
156    /// The menu will be positioned relative to this rectangle.
157    ///
158    /// # Arguments
159    /// * `rect` - The rectangle to anchor the menu to
160    ///
161    /// # Example
162    /// ```rust
163    /// # egui::__run_test_ui(|ui| {
164    /// let mut menu_open = false;
165    /// let button_rect = ui.available_rect_before_wrap();
166    /// let menu = MaterialMenu::new("menu", &mut menu_open)
167    ///     .anchor_rect(button_rect);
168    /// # });
169    /// ```
170    pub fn anchor_rect(mut self, rect: Rect) -> Self {
171        self.anchor_rect = Some(rect);
172        self
173    }
174
175    /// Add an item to the menu.
176    ///
177    /// # Arguments
178    /// * `item` - The menu item to add
179    ///
180    /// # Example
181    /// ```rust
182    /// # egui::__run_test_ui(|ui| {
183    /// let mut menu_open = false;
184    /// let item = MenuItem::new("Cut").action(|| println!("Cut"));
185    /// let menu = MaterialMenu::new("menu", &mut menu_open).item(item);
186    /// # });
187    /// ```
188    pub fn item(mut self, item: MenuItem<'a>) -> Self {
189        self.items.push(item);
190        self
191    }
192
193    /// Set the elevation (shadow) of the menu.
194    ///
195    /// Material Design defines elevation levels from 0 to 24.
196    /// Higher values create more prominent shadows.
197    ///
198    /// # Arguments
199    /// * `elevation` - Elevation level (0-24)
200    ///
201    /// # Example
202    /// ```rust
203    /// # egui::__run_test_ui(|ui| {
204    /// let mut menu_open = false;
205    /// let menu = MaterialMenu::new("menu", &mut menu_open).elevation(8);
206    /// # });
207    /// ```
208    pub fn elevation(mut self, elevation: u8) -> Self {
209        self.elevation = elevation;
210        self
211    }
212
213    /// Set the anchor corner for the menu.
214    pub fn anchor_corner(mut self, corner: Corner) -> Self {
215        self.anchor_corner = corner;
216        self
217    }
218
219    /// Set the menu corner for positioning.
220    pub fn menu_corner(mut self, corner: Corner) -> Self {
221        self.menu_corner = corner;
222        self
223    }
224
225    /// Set the default focus state for the menu.
226    pub fn default_focus(mut self, focus: FocusState) -> Self {
227        self.default_focus = focus;
228        self
229    }
230
231    /// Set the positioning mode for the menu.
232    pub fn positioning(mut self, positioning: Positioning) -> Self {
233        self.positioning = positioning;
234        self
235    }
236
237    /// Enable or disable quick animation for the menu.
238    pub fn quick(mut self, quick: bool) -> Self {
239        self.quick = quick;
240        self
241    }
242
243    /// Enable or disable overflow scrolling for the menu.
244    pub fn has_overflow(mut self, has_overflow: bool) -> Self {
245        self.has_overflow = has_overflow;
246        self
247    }
248
249    /// Keep the menu open when clicking outside of it.
250    pub fn stay_open_on_outside_click(mut self, stay_open: bool) -> Self {
251        self.stay_open_on_outside_click = stay_open;
252        self
253    }
254
255    /// Keep the menu open when focus moves away from it.
256    pub fn stay_open_on_focusout(mut self, stay_open: bool) -> Self {
257        self.stay_open_on_focusout = stay_open;
258        self
259    }
260
261    /// Skip restoring focus when the menu closes.
262    pub fn skip_restore_focus(mut self, skip: bool) -> Self {
263        self.skip_restore_focus = skip;
264        self
265    }
266
267    /// Set the horizontal offset for the menu.
268    pub fn x_offset(mut self, offset: f32) -> Self {
269        self.x_offset = offset;
270        self
271    }
272
273    /// Set the vertical offset for the menu.
274    pub fn y_offset(mut self, offset: f32) -> Self {
275        self.y_offset = offset;
276        self
277    }
278
279    /// Prevent horizontal flipping when the menu would go offscreen.
280    pub fn no_horizontal_flip(mut self, no_flip: bool) -> Self {
281        self.no_horizontal_flip = no_flip;
282        self
283    }
284
285    /// Prevent vertical flipping when the menu would go offscreen.
286    pub fn no_vertical_flip(mut self, no_flip: bool) -> Self {
287        self.no_vertical_flip = no_flip;
288        self
289    }
290
291    /// Set the typeahead delay for the menu.
292    pub fn typeahead_delay(mut self, delay: f32) -> Self {
293        self.typeahead_delay = delay;
294        self
295    }
296
297    /// Set the tab index for keyboard navigation.
298    pub fn list_tab_index(mut self, index: i32) -> Self {
299        self.list_tab_index = index;
300        self
301    }
302
303    /// Show the menu in the given context.
304    pub fn show(self, ctx: &Context) {
305        if !*self.open {
306            return;
307        }
308
309        // Use a stable ID for the menu
310        let stable_id = egui::Id::new(format!("menu_{}", self.id.value()));
311        
312        // Track if this is the frame when menu was opened
313        let was_opened_this_frame = ctx.data_mut(|d| {
314            let last_open_state = d.get_temp::<bool>(stable_id.with("was_open_last_frame")).unwrap_or(false);
315            let just_opened = !last_open_state && *self.open;
316            d.insert_temp(stable_id.with("was_open_last_frame"), *self.open);
317            just_opened
318        });
319        
320        // Request focus when menu opens
321        if was_opened_this_frame && !self.skip_restore_focus {
322            ctx.memory_mut(|mem| mem.request_focus(stable_id));
323        }
324
325        let item_height = 48.0;
326        let total_height = self.items.len() as f32 * item_height + 
327                          self.items.iter().filter(|item| item.divider_after).count() as f32;
328        let menu_width = 280.0;
329
330        let menu_size = Vec2::new(menu_width, total_height);
331
332        // Determine position based on anchor corner and menu corner
333        let position = if let Some(anchor) = self.anchor_rect {
334            let anchor_point = match self.anchor_corner {
335                Corner::TopLeft => anchor.min,
336                Corner::TopRight => Pos2::new(anchor.max.x, anchor.min.y),
337                Corner::BottomLeft => Pos2::new(anchor.min.x, anchor.max.y),
338                Corner::BottomRight => anchor.max,
339            };
340
341            let menu_offset = match self.menu_corner {
342                Corner::TopLeft => Vec2::ZERO,
343                Corner::TopRight => Vec2::new(-menu_size.x, 0.0),
344                Corner::BottomLeft => Vec2::new(0.0, -menu_size.y),
345                Corner::BottomRight => -menu_size,
346            };
347
348            // Apply the corner positioning and offsets
349            let base_position = anchor_point + menu_offset;
350            Pos2::new(
351                base_position.x + self.x_offset,
352                base_position.y + self.y_offset + 4.0, // 4px spacing from anchor
353            )
354        } else {
355            // Center on screen
356            let screen_rect = ctx.screen_rect();
357            screen_rect.center() - menu_size / 2.0
358        };
359
360        let open_ref = self.open;
361        let _id = self.id;
362        let items = self.items;
363        let elevation = self.elevation;
364        let stay_open_on_outside_click = self.stay_open_on_outside_click;
365        let _stay_open_on_focusout = self.stay_open_on_focusout;
366
367        // Create a popup window for the menu with a stable layer and unique ID
368        let _area_response = egui::Area::new(stable_id)
369            .fixed_pos(position)
370            .order(egui::Order::Foreground)
371            .interactable(true)
372            .show(ctx, |ui| {
373                render_menu_content(ui, menu_size, items, elevation, open_ref)
374            });
375
376        // Handle closing behavior based on settings
377        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
378            *open_ref = false;
379        } else if !stay_open_on_outside_click && !was_opened_this_frame {
380            // Only handle outside clicks if not staying open and not just opened
381            if ctx.input(|i| i.pointer.any_click()) {
382                let pointer_pos = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
383                let menu_rect = Rect::from_min_size(position, menu_size);
384                
385                // Include anchor rect in the "inside" area to prevent closing when clicking trigger
386                let mut inside_area = menu_rect;
387                if let Some(anchor) = self.anchor_rect {
388                    inside_area = inside_area.union(anchor);
389                }
390                
391                // Only close if click was outside both menu and anchor areas
392                if !inside_area.contains(pointer_pos) {
393                    *open_ref = false;
394                }
395            }
396        }
397    }
398
399}
400
401fn render_menu_content<'a>(ui: &mut Ui, size: Vec2, items: Vec<MenuItem<'a>>, elevation: u8, open_ref: &'a mut bool) -> Response {
402    let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
403
404    // Material Design colors
405    let surface_container = get_global_color("surfaceContainer");
406    let on_surface = get_global_color("onSurface");
407    let on_surface_variant = get_global_color("onSurfaceVariant");
408    let outline_variant = get_global_color("outlineVariant");
409
410    // Draw shadow for elevation
411    let shadow_offset = elevation as f32 * 2.0;
412    let shadow_rect = rect.expand(shadow_offset);
413    ui.painter().rect_filled(
414        shadow_rect,
415        8.0,
416        Color32::from_black_alpha((elevation * 10).min(80)),
417    );
418
419    // Draw menu background
420    ui.painter().rect_filled(rect, 8.0, surface_container);
421
422    // Draw menu border
423    ui.painter().rect_stroke(
424        rect,
425        8.0,
426        Stroke::new(1.0, outline_variant),
427        egui::epaint::StrokeKind::Outside,
428    );
429
430    let mut current_y = rect.min.y + 8.0;
431    let mut pending_actions = Vec::new();
432    let mut should_close = false;
433
434    for (index, item) in items.into_iter().enumerate() {
435        let item_rect = Rect::from_min_size(
436            Pos2::new(rect.min.x + 8.0, current_y),
437            Vec2::new(rect.width() - 16.0, 48.0),
438        );
439
440        let item_response = ui.interact(
441            item_rect,
442            egui::Id::new(format!("menu_item_{}_{}", rect.min.x as i32, index)),
443            Sense::click(),
444        );
445
446        // Draw item background on hover
447        if item_response.hovered() && item.enabled {
448            let hover_color = Color32::from_rgba_premultiplied(
449                on_surface.r(), on_surface.g(), on_surface.b(), 20
450            );
451            ui.painter().rect_filled(item_rect, 4.0, hover_color);
452        }
453
454        // Handle click
455        if item_response.clicked() && item.enabled {
456            if let Some(action) = item.action {
457                pending_actions.push(action);
458                // Only close menu after item click
459                should_close = true;
460            }
461        }
462
463        // Layout item content
464        let mut content_x = item_rect.min.x + 12.0;
465        let content_y = item_rect.center().y;
466
467        // Draw leading icon
468        if let Some(_icon) = &item.leading_icon {
469            let icon_rect = Rect::from_min_size(
470                Pos2::new(content_x, content_y - 12.0),
471                Vec2::splat(24.0),
472            );
473            
474            let icon_color = if item.enabled { on_surface_variant } else {
475                get_global_color("outline")
476            };
477
478            ui.painter().circle_filled(icon_rect.center(), 8.0, icon_color);
479            content_x += 36.0;
480        }
481
482        // Draw text
483        let text_color = if item.enabled { on_surface } else {
484            get_global_color("outline")
485        };
486
487        let text_pos = Pos2::new(content_x, content_y);
488        ui.painter().text(
489            text_pos,
490            egui::Align2::LEFT_CENTER,
491            &item.text,
492            egui::FontId::default(),
493            text_color,
494        );
495
496        // Draw trailing icon
497        if let Some(_icon) = &item.trailing_icon {
498            let icon_rect = Rect::from_min_size(
499                Pos2::new(item_rect.max.x - 36.0, content_y - 12.0),
500                Vec2::splat(24.0),
501            );
502            
503            let icon_color = if item.enabled { on_surface_variant } else {
504                get_global_color("outline")
505            };
506
507            ui.painter().circle_filled(icon_rect.center(), 8.0, icon_color);
508        }
509
510        current_y += 48.0;
511
512        // Draw divider
513        if item.divider_after {
514            let divider_y = current_y;
515            let divider_start = Pos2::new(rect.min.x + 12.0, divider_y);
516            let divider_end = Pos2::new(rect.max.x - 12.0, divider_y);
517            
518            ui.painter().line_segment(
519                [divider_start, divider_end],
520                Stroke::new(1.0, outline_variant),
521            );
522            current_y += 1.0;
523        }
524    }
525
526    // Execute pending actions
527    for action in pending_actions {
528        action();
529    }
530
531    if should_close {
532        *open_ref = false;
533    }
534
535    response
536}
537
538impl<'a> MenuItem<'a> {
539    /// Create a new menu item.
540    ///
541    /// # Arguments
542    /// * `text` - Display text for the menu item
543    ///
544    /// # Example
545    /// ```rust
546    /// let item = MenuItem::new("Copy");
547    /// ```
548    pub fn new(text: impl Into<String>) -> Self {
549        Self {
550            text: text.into(),
551            leading_icon: None,
552            trailing_icon: None,
553            enabled: true,
554            divider_after: false,
555            action: None,
556        }
557    }
558
559    /// Set the leading icon for the menu item.
560    ///
561    /// # Arguments
562    /// * `icon` - Icon identifier (e.g., "copy", "cut", "paste")
563    ///
564    /// # Example
565    /// ```rust
566    /// let item = MenuItem::new("Copy").leading_icon("content_copy");
567    /// ```
568    pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
569        self.leading_icon = Some(icon.into());
570        self
571    }
572
573    /// Set the trailing icon for the menu item.
574    ///
575    /// # Arguments
576    /// * `icon` - Icon identifier (e.g., "keyboard_arrow_right", "check")
577    ///
578    /// # Example
579    /// ```rust
580    /// let item = MenuItem::new("Save").trailing_icon("keyboard_arrow_right");
581    /// ```
582    pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
583        self.trailing_icon = Some(icon.into());
584        self
585    }
586
587    /// Enable or disable the menu item.
588    ///
589    /// # Arguments
590    /// * `enabled` - Whether the menu item should be interactive
591    ///
592    /// # Example
593    /// ```rust
594    /// let item = MenuItem::new("Paste").enabled(false); // Disabled item
595    /// ```
596    pub fn enabled(mut self, enabled: bool) -> Self {
597        self.enabled = enabled;
598        self
599    }
600
601    /// Add a divider after the menu item.
602    ///
603    /// # Arguments
604    /// * `divider` - Whether to show a divider line after this item
605    ///
606    /// # Example
607    /// ```rust
608    /// let item = MenuItem::new("Copy").divider_after(true); // Show divider after this item
609    /// ```
610    pub fn divider_after(mut self, divider: bool) -> Self {
611        self.divider_after = divider;
612        self
613    }
614
615    /// Set the action to be performed when the menu item is clicked.
616    ///
617    /// # Arguments
618    /// * `f` - Closure to execute when the item is clicked
619    ///
620    /// # Example
621    /// ```rust
622    /// let item = MenuItem::new("Delete")
623    ///     .on_click(|| println!("Item deleted"));
624    /// ```
625    pub fn on_click<F>(mut self, f: F) -> Self
626    where
627        F: Fn() + 'a,
628    {
629        self.action = Some(Box::new(f));
630        self
631    }
632}
633
634/// Convenience function to create a new menu instance.
635///
636/// Shorthand for `MaterialMenu::new()`.
637///
638/// # Arguments
639/// * `id` - Unique identifier for this menu
640/// * `open` - Mutable reference to the menu's open state
641///
642/// # Example
643/// ```rust
644/// # egui::__run_test_ui(|ui| {
645/// let mut menu_open = false;
646/// let menu = menu("context_menu", &mut menu_open);
647/// # });
648/// ```
649pub fn menu(id: impl Into<egui::Id>, open: &mut bool) -> MaterialMenu<'_> {
650    MaterialMenu::new(id, open)
651}
652
653/// Convenience function to create a new menu item.
654///
655/// Shorthand for `MenuItem::new()`.
656///
657/// # Arguments
658/// * `text` - Display text for the menu item
659///
660/// # Example
661/// ```rust
662/// let item = menu_item("Copy")
663///     .leading_icon("content_copy")
664///     .on_click(|| println!("Copy action"));
665/// ```
666pub fn menu_item(text: impl Into<String>) -> MenuItem<'static> {
667    MenuItem::new(text)
668}