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}