elegance/menu.rs
1//! Action menus — a popup list of items attached to a trigger [`Response`].
2//!
3//! [`Menu`] opens a themed popup below a trigger widget when the trigger is
4//! clicked. [`MenuItem`] is the styled leaf inside the popup: label on the
5//! left, optional keyboard-shortcut hint on the right, optional `danger`
6//! tint for destructive actions. Separators between groups use the stock
7//! `ui.separator()`.
8//!
9//! ```no_run
10//! # use elegance::{Button, ButtonSize, Menu, MenuItem};
11//! # egui::__run_test_ui(|ui| {
12//! let trigger = ui.add(Button::new("⋯").outline().size(ButtonSize::Small));
13//! Menu::new("row_actions").show_below(&trigger, |ui| {
14//! if ui.add(MenuItem::new("Edit").shortcut("⌘ E")).clicked() {
15//! // …
16//! }
17//! if ui.add(MenuItem::new("Duplicate")).clicked() { /* … */ }
18//! ui.separator();
19//! if ui.add(MenuItem::new("Delete").danger()).clicked() { /* … */ }
20//! });
21//! # });
22//! ```
23//!
24//! The popup is dismissed by clicking any item, clicking outside, or
25//! pressing `Esc`. Keyboard navigation (arrows + Enter) is not implemented
26//! in this version.
27
28use std::hash::Hash;
29
30use egui::{
31 CornerRadius, Id, Popup, PopupCloseBehavior, Pos2, Response, Sense, Ui, Vec2, Widget,
32 WidgetInfo, WidgetText, WidgetType,
33};
34
35use crate::theme::{with_alpha, Theme};
36
37/// A click-to-open popup menu anchored below a trigger [`Response`].
38///
39/// Call [`Menu::show_below`] after painting the trigger; it opens on
40/// trigger clicks and closes on item click, outside-click, or `Esc`.
41#[derive(Debug, Clone)]
42#[must_use = "Call `.show_below(&trigger, |ui| ...)` to render the menu."]
43pub struct Menu {
44 id_salt: Id,
45 min_width: f32,
46}
47
48impl Menu {
49 /// Create a menu keyed by `id_salt`. The salt is used to persist the
50 /// open/closed state across frames and must be stable for the trigger
51 /// it's attached to.
52 pub fn new(id_salt: impl Hash) -> Self {
53 Self {
54 id_salt: Id::new(("elegance::menu", Id::new(id_salt))),
55 min_width: 180.0,
56 }
57 }
58
59 /// Minimum width of the popup in points. Default: 180.
60 pub fn min_width(mut self, min_width: f32) -> Self {
61 self.min_width = min_width;
62 self
63 }
64
65 /// Render the menu below `trigger`. Returns `Some(R)` with the body
66 /// closure's return value while the menu is open, `None` while closed.
67 pub fn show_below<R>(
68 self,
69 trigger: &Response,
70 add_contents: impl FnOnce(&mut Ui) -> R,
71 ) -> Option<R> {
72 let popup_id = Id::new(self.id_salt);
73 Popup::menu(trigger)
74 .id(popup_id)
75 .close_behavior(PopupCloseBehavior::CloseOnClick)
76 .show(|ui| {
77 ui.set_min_width(self.min_width);
78 // Tight stacking — MenuItem has its own interior padding.
79 ui.spacing_mut().item_spacing.y = 2.0;
80 add_contents(ui)
81 })
82 .map(|r| r.inner)
83 }
84}
85
86/// A single selectable row inside a [`Menu`].
87///
88/// Add with `ui.add(MenuItem::new("…"))` inside a menu body. The returned
89/// [`Response`]'s `.clicked()` fires on activation.
90///
91/// The optional [`icon`](Self::icon), [`checked`](Self::checked), and
92/// [`radio`](Self::radio) builders all reserve the same leading gutter,
93/// so toggle items in a menu align cleanly with action items as long as
94/// every item in that menu opts into one of them.
95#[must_use = "Add with `ui.add(...)`."]
96pub struct MenuItem {
97 label: WidgetText,
98 shortcut: Option<String>,
99 danger: bool,
100 enabled: bool,
101 leading: Option<Leading>,
102 submenu_arrow: bool,
103}
104
105#[derive(Clone)]
106enum Leading {
107 Icon(WidgetText),
108 Checked(bool),
109 Radio(bool),
110}
111
112impl std::fmt::Debug for MenuItem {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("MenuItem")
115 .field("label", &self.label.text())
116 .field("shortcut", &self.shortcut)
117 .field("danger", &self.danger)
118 .field("enabled", &self.enabled)
119 .field("leading", &self.leading.is_some())
120 .finish()
121 }
122}
123
124impl MenuItem {
125 /// Create a menu item with the given label.
126 pub fn new(label: impl Into<WidgetText>) -> Self {
127 Self {
128 label: label.into(),
129 shortcut: None,
130 danger: false,
131 enabled: true,
132 leading: None,
133 submenu_arrow: false,
134 }
135 }
136
137 #[doc(hidden)]
138 /// Render a body-sized right chevron in the right gutter to mark a
139 /// submenu trigger. Hidden because callers should reach for
140 /// [`SubMenuItem`] rather than building this on the raw `MenuItem`.
141 pub fn with_submenu_arrow(mut self) -> Self {
142 self.submenu_arrow = true;
143 self
144 }
145
146 /// Display a keyboard-shortcut hint on the right (informational only —
147 /// the actual shortcut is not bound).
148 pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
149 self.shortcut = Some(shortcut.into());
150 self
151 }
152
153 /// Render the item in the danger tone — red label, red hover highlight.
154 /// Use for destructive actions.
155 pub fn danger(mut self) -> Self {
156 self.danger = true;
157 self
158 }
159
160 /// Disable the item. Disabled items do not fire `clicked()` and render
161 /// with muted text.
162 pub fn enabled(mut self, enabled: bool) -> Self {
163 self.enabled = enabled;
164 self
165 }
166
167 /// Show a leading icon (any text, typically a unicode glyph) in the
168 /// gutter to the left of the label. Reserves the gutter even when the
169 /// glyph is narrow, so adjacent items align.
170 pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
171 self.leading = Some(Leading::Icon(icon.into()));
172 self
173 }
174
175 /// Render the item as a checkbox toggle: a checkmark in the leading
176 /// gutter when `on`, an empty gutter when off. The item is announced
177 /// via accesskit as a checkbox with the given selected state.
178 pub fn checked(mut self, on: bool) -> Self {
179 self.leading = Some(Leading::Checked(on));
180 self
181 }
182
183 /// Render the item as a radio-button toggle: a filled dot in the
184 /// leading gutter when `on`, an empty gutter when off. Use within a
185 /// group of mutually-exclusive choices. Announced via accesskit as a
186 /// radio button.
187 pub fn radio(mut self, on: bool) -> Self {
188 self.leading = Some(Leading::Radio(on));
189 self
190 }
191}
192
193impl Widget for MenuItem {
194 fn ui(self, ui: &mut Ui) -> Response {
195 let theme = Theme::current(ui.ctx());
196 let p = &theme.palette;
197 let t = &theme.typography;
198
199 let pad_x = 10.0;
200 let pad_y = 6.0;
201 let gap_x = 16.0;
202 let gutter_w = 16.0; // Reserved leading-glyph slot.
203 let gutter_gap = 8.0;
204
205 let label_color = if !self.enabled {
206 p.text_faint
207 } else if self.danger {
208 p.danger
209 } else {
210 p.text
211 };
212
213 let label_galley =
214 crate::theme::placeholder_galley(ui, self.label.text(), t.body, false, f32::INFINITY);
215
216 let shortcut_galley = self
217 .shortcut
218 .as_deref()
219 .map(|s| crate::theme::placeholder_galley(ui, s, t.small, false, f32::INFINITY));
220
221 // Submenu arrow: a `›` glyph rendered well above body size so it
222 // reads as a flyout indicator rather than a small auxiliary
223 // mark.
224 let submenu_arrow_galley = if self.submenu_arrow {
225 Some(crate::theme::placeholder_galley(
226 ui,
227 "\u{203A}",
228 24.0,
229 false,
230 f32::INFINITY,
231 ))
232 } else {
233 None
234 };
235
236 let leading_glyph_galley = match &self.leading {
237 Some(Leading::Icon(icon)) => Some(crate::theme::placeholder_galley(
238 ui,
239 icon.text(),
240 t.body,
241 false,
242 f32::INFINITY,
243 )),
244 Some(Leading::Checked(true)) => Some(crate::theme::placeholder_galley(
245 ui,
246 "\u{2713}",
247 t.body,
248 true,
249 f32::INFINITY,
250 )),
251 Some(Leading::Radio(true)) => Some(crate::theme::placeholder_galley(
252 ui,
253 "\u{2022}",
254 t.body,
255 true,
256 f32::INFINITY,
257 )),
258 // Off-state toggles still reserve the gutter so siblings align.
259 Some(Leading::Checked(false)) | Some(Leading::Radio(false)) => None,
260 None => None,
261 };
262
263 let leading_offset = if self.leading.is_some() {
264 gutter_w + gutter_gap
265 } else {
266 0.0
267 };
268
269 let trailing_w = shortcut_galley
270 .as_ref()
271 .map_or(0.0, |g| g.size().x + gap_x)
272 .max(
273 submenu_arrow_galley
274 .as_ref()
275 .map_or(0.0, |g| g.size().x + gap_x),
276 );
277 let content_w = leading_offset + label_galley.size().x + trailing_w;
278 // Width is the natural content size: the parent menu's
279 // `top_down_justified` layout stretches each row to match the
280 // widest one, and the popup as a whole sizes to that maximum.
281 // Don't `.max(available_width)` here — that would let each item
282 // greedily expand to whatever space the parent offers, which
283 // makes the popup balloon out to its container's width.
284 let desired = Vec2::new(
285 content_w + pad_x * 2.0,
286 label_galley.size().y.max(t.body) + pad_y * 2.0,
287 );
288
289 let sense = if self.enabled {
290 Sense::click()
291 } else {
292 Sense::hover()
293 };
294 // `allocate_at_least` returns the full layout slot rect (which,
295 // under `top_down_justified`, expands to the widest sibling).
296 // We need that here so the trailing shortcut right-aligns with
297 // the popup's right edge — `allocate_exact_size` would clamp the
298 // returned rect to `desired` and the shortcut would sit right
299 // after the label.
300 let (rect, response) = ui.allocate_at_least(desired, sense);
301
302 if ui.is_rect_visible(rect) {
303 let is_hovered = response.hovered() && self.enabled;
304 if is_hovered {
305 let bg = if self.danger {
306 with_alpha(p.red, 40)
307 } else {
308 with_alpha(p.sky, 28)
309 };
310 let radius = CornerRadius::same((theme.control_radius as u8).saturating_sub(2));
311 ui.painter().rect_filled(rect, radius, bg);
312 }
313
314 if let Some(glyph) = leading_glyph_galley {
315 // Centre the glyph within the gutter slot.
316 let slot_x = rect.min.x + pad_x;
317 let glyph_color = match &self.leading {
318 Some(Leading::Checked(true)) | Some(Leading::Radio(true)) => p.sky,
319 _ if !self.enabled => p.text_faint,
320 _ => p.text_muted,
321 };
322 let pos = Pos2::new(
323 slot_x + (gutter_w - glyph.size().x) * 0.5,
324 rect.center().y - glyph.size().y * 0.5,
325 );
326 ui.painter().galley(pos, glyph, glyph_color);
327 }
328
329 let label_pos = Pos2::new(
330 rect.min.x + pad_x + leading_offset,
331 rect.center().y - label_galley.size().y * 0.5,
332 );
333 ui.painter().galley(label_pos, label_galley, label_color);
334
335 if let Some(galley) = shortcut_galley {
336 let pos = Pos2::new(
337 rect.max.x - pad_x - galley.size().x,
338 rect.center().y - galley.size().y * 0.5,
339 );
340 let color = if !self.enabled {
341 p.text_faint
342 } else if self.danger {
343 with_alpha(p.danger, 200)
344 } else {
345 p.text_muted
346 };
347 ui.painter().galley(pos, galley, color);
348 }
349
350 if let Some(galley) = submenu_arrow_galley {
351 let pos = Pos2::new(
352 rect.max.x - pad_x - galley.size().x,
353 rect.center().y - galley.size().y * 0.5,
354 );
355 let color = if !self.enabled {
356 p.text_faint
357 } else {
358 p.text_muted
359 };
360 ui.painter().galley(pos, galley, color);
361 }
362 }
363
364 response.widget_info(|| match &self.leading {
365 Some(Leading::Checked(on)) => {
366 WidgetInfo::selected(WidgetType::Checkbox, self.enabled, *on, self.label.text())
367 }
368 Some(Leading::Radio(on)) => WidgetInfo::selected(
369 WidgetType::RadioButton,
370 self.enabled,
371 *on,
372 self.label.text(),
373 ),
374 _ => WidgetInfo::labeled(WidgetType::Button, self.enabled, self.label.text()),
375 });
376 response
377 }
378}
379
380/// A menu row that opens a flyout submenu when hovered.
381///
382/// Visually a [`MenuItem`] with a right-pointing chevron; pair the
383/// trigger with a body closure that fills the child menu. Use inside any
384/// elegance menu — a [`MenuBar`](crate::MenuBar) dropdown body or a
385/// [`Menu`] popup. The submenu opens to the right and stays open while
386/// the pointer remains over either the trigger or the flyout panel.
387///
388/// ```no_run
389/// # use elegance::{MenuBar, MenuItem, SubMenuItem};
390/// # egui::__run_test_ui(|ui| {
391/// MenuBar::new("app").show(ui, |bar| {
392/// bar.menu("File", |ui| {
393/// ui.add(MenuItem::new("New"));
394/// SubMenuItem::new("Open Recent").show(ui, |ui| {
395/// ui.add(MenuItem::new("theme.rs"));
396/// ui.add(MenuItem::new("README.md"));
397/// });
398/// ui.add(MenuItem::new("Save"));
399/// });
400/// });
401/// # });
402/// ```
403///
404/// Hover-to-flyout, click-to-pin, and proper "stay open while child is
405/// open" behavior come from `egui`'s built-in submenu machinery; this
406/// type just wires our [`MenuItem`] visual into that pipeline.
407#[must_use = "Call `.show(ui, |ui| ...)` to render the submenu trigger and flyout."]
408pub struct SubMenuItem {
409 label: WidgetText,
410 icon: Option<WidgetText>,
411 enabled: bool,
412}
413
414impl std::fmt::Debug for SubMenuItem {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 f.debug_struct("SubMenuItem")
417 .field("label", &self.label.text())
418 .field("icon", &self.icon.is_some())
419 .field("enabled", &self.enabled)
420 .finish()
421 }
422}
423
424impl SubMenuItem {
425 /// Create a new submenu trigger with the given label.
426 pub fn new(label: impl Into<WidgetText>) -> Self {
427 Self {
428 label: label.into(),
429 icon: None,
430 enabled: true,
431 }
432 }
433
434 /// Show a leading icon (any text, typically a unicode glyph) in the
435 /// gutter to the left of the label.
436 pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
437 self.icon = Some(icon.into());
438 self
439 }
440
441 /// Disable the submenu trigger. Disabled triggers do not open the
442 /// flyout and render with muted text.
443 pub fn enabled(mut self, enabled: bool) -> Self {
444 self.enabled = enabled;
445 self
446 }
447
448 /// Render the trigger row and attach the flyout submenu. The body
449 /// closure populates the child menu and is invoked while the flyout
450 /// is open. Returns `Some(R)` with the body's return value while the
451 /// submenu is open, `None` while closed.
452 pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut Ui) -> R) -> Option<R> {
453 let mut item = MenuItem::new(self.label)
454 .enabled(self.enabled)
455 .with_submenu_arrow();
456 if let Some(icon) = self.icon {
457 item = item.icon(icon);
458 }
459 let response = ui.add(item);
460 sub_menu_show(ui, &response, body)
461 }
462}
463
464/// Open a flyout submenu next to `button_response`, with a wider gap than
465/// `egui::containers::menu::SubMenu` produces by default.
466///
467/// This vendors egui 0.34's `SubMenu::show` logic verbatim so we keep its
468/// hover/click/close-behavior semantics intact, but bumps the popup's
469/// `gap` so the flyout reads as a visually distinct panel rather than a
470/// continuation of the parent menu. egui hard-codes the gap as
471/// `frame.margin / 2 + 2` (~8 pt with our menu_margin), which is enough
472/// for the popup to clear the parent's border but still close enough
473/// that, with two same-coloured `Frame::menu` panels side by side, they
474/// read as one wider menu.
475fn sub_menu_show<R>(
476 ui: &Ui,
477 button_response: &Response,
478 content: impl FnOnce(&mut Ui) -> R,
479) -> Option<R> {
480 use egui::containers::menu::{MenuConfig, MenuState};
481 use egui::{
482 emath::{Align, RectAlign},
483 pos2, Frame, Layout, Margin, PointerButton, Popup, PopupCloseBehavior, Rect, UiKind,
484 UiStackInfo,
485 };
486
487 // Horizontal gap between the trigger row's right edge and the
488 // submenu's left edge. Since the submenu also drops downward off
489 // the trigger row's bottom (see the anchor manipulation below),
490 // there's already a clear vertical break between the two panels;
491 // we don't need extra horizontal offset to read them as distinct.
492 const GAP: f32 = 0.0;
493
494 // Tighten the submenu's vertical padding to match our top-level
495 // dropdown's `inner_margin: 4` so the first submenu item sits close
496 // to the panel's top edge instead of dropping ~6 pt below it.
497 let frame = Frame::menu(ui.style()).inner_margin(Margin::same(4));
498 let id = button_response.id.with("submenu");
499
500 let (open_item, menu_id) = MenuState::from_ui(ui, |state, stack| (state.open_item, stack.id));
501 // `MenuConfig::find` walks the stack from the parent menu (we haven't
502 // shown the submenu yet, so the current ui's stack tail is the
503 // parent). The submenu inherits the parent's close behavior and
504 // style.
505 let menu_config = MenuConfig::find(ui);
506
507 let menu_root_response = ui
508 .ctx()
509 .read_response(menu_id)
510 .expect("submenu must be inside a menu");
511 let hover_pos = ui.ctx().pointer_hover_pos();
512 let menu_rect = menu_root_response.rect - frame.total_margin();
513 let is_hovering_menu = hover_pos.is_some_and(|pos| {
514 ui.ctx().layer_id_at(pos) == Some(menu_root_response.layer_id) && menu_rect.contains(pos)
515 });
516
517 let is_any_open = open_item.is_some();
518 let mut is_open = open_item == Some(id);
519 let was_open = is_open;
520 let mut set_open: Option<bool> = None;
521
522 let button_rect = button_response
523 .rect
524 .expand2(ui.style().spacing.item_spacing / 2.0);
525 let is_hovered = hover_pos.is_some_and(|pos| button_rect.contains(pos));
526
527 let clicked = button_response.clicked();
528 let clicked_by_pointer = button_response.clicked_by(PointerButton::Primary);
529 let clicked_by_keyboard_or_access = clicked && !clicked_by_pointer;
530
531 if ui.is_enabled() && is_open && clicked_by_keyboard_or_access {
532 set_open = Some(false);
533 is_open = false;
534 }
535
536 let should_open = ui.is_enabled() && ((!was_open && clicked) || (is_hovered && !is_any_open));
537 if should_open {
538 set_open = Some(true);
539 is_open = true;
540 MenuState::from_id(ui.ctx(), menu_id, |state| {
541 state.open_item = None;
542 });
543 }
544
545 // Anchor the popup to a zero-height strip along the trigger row's
546 // bottom, indented from the row's left edge by `LEFT_INSET` so a
547 // `BOTTOM_START` align drops the submenu down with its left edge
548 // *inside* the trigger row (rather than column-flush with the
549 // parent menu). 24pt aligns roughly with where the trigger's label
550 // text starts past the leading icon gutter, so the submenu reads
551 // as continuing from the trigger's content rather than from the
552 // panel's edge. We only touch `interact_rect` (what
553 // `Popup::from_response` uses for anchoring); hover/click
554 // detection further down still consults the original `rect`.
555 const LEFT_INSET: f32 = 24.0;
556 let mut response = button_response.clone();
557 let bottom = button_response.rect.bottom();
558 let left = (button_response.rect.left() + LEFT_INSET).min(button_response.rect.right());
559 response.interact_rect = Rect::from_min_max(
560 pos2(left, bottom),
561 pos2(button_response.rect.right(), bottom),
562 );
563
564 let popup_response = Popup::from_response(&response)
565 .id(id)
566 .open(is_open)
567 // Drop straight down from the trigger row: popup top-left aligns
568 // with the anchor strip's left-bottom, so the submenu shares a
569 // column with the trigger and grows to the right only as wide as
570 // its contents need.
571 .align(RectAlign::BOTTOM_START)
572 // Pin the alignment — without this, egui's `find_best_align`
573 // tries `RectAlign::MENU_ALIGNS` as fallbacks if it thinks our
574 // requested side doesn't fit, and silently picks a different
575 // alignment, which is what makes our `gap` look like it has no
576 // effect when the parent menu sits on a side of the viewport.
577 .align_alternatives(&[])
578 .layout(Layout::top_down_justified(Align::Min))
579 .gap(GAP)
580 .style(menu_config.style.clone())
581 .frame(frame)
582 .close_behavior(PopupCloseBehavior::IgnoreClicks)
583 .info(
584 UiStackInfo::new(UiKind::Menu)
585 .with_tag_value(MenuConfig::MENU_CONFIG_TAG, menu_config.clone()),
586 )
587 .show(|ui| {
588 if button_response.clicked() || button_response.is_pointer_button_down_on() {
589 ui.ctx().move_to_top(ui.layer_id());
590 }
591 content(ui)
592 });
593
594 if let Some(popup_response) = &popup_response {
595 let is_deepest_submenu = MenuState::is_deepest_open_sub_menu(ui.ctx(), id);
596 let clicked_outside = is_deepest_submenu
597 && popup_response.response.clicked_elsewhere()
598 && menu_root_response.clicked_elsewhere();
599 let submenu_button_clicked = button_response.clicked();
600 let clicked_inside = is_deepest_submenu
601 && !submenu_button_clicked
602 && response.ctx.input(|i| i.pointer.any_click())
603 && hover_pos.is_some_and(|pos| popup_response.response.interact_rect.contains(pos));
604
605 let click_close = match menu_config.close_behavior {
606 PopupCloseBehavior::CloseOnClick => clicked_outside || clicked_inside,
607 PopupCloseBehavior::CloseOnClickOutside => clicked_outside,
608 PopupCloseBehavior::IgnoreClicks => false,
609 };
610
611 if click_close {
612 set_open = Some(false);
613 }
614
615 let is_moving_towards_rect = ui.input(|i| {
616 i.pointer
617 .is_moving_towards_rect(&popup_response.response.rect)
618 });
619 if is_moving_towards_rect {
620 ui.ctx().request_repaint();
621 }
622 let hovering_other_menu_entry = is_open
623 && !is_hovered
624 && !popup_response.response.contains_pointer()
625 && !is_moving_towards_rect
626 && is_hovering_menu;
627
628 if hovering_other_menu_entry {
629 set_open = Some(false);
630 }
631 }
632
633 if let Some(open) = set_open {
634 MenuState::from_id(ui.ctx(), menu_id, |state| {
635 state.open_item = open.then_some(id);
636 });
637 }
638
639 if is_open {
640 MenuState::mark_shown(ui.ctx(), id);
641 }
642
643 popup_response.map(|r| r.inner)
644}
645
646/// A small uppercase header used to label a group of items inside a
647/// [`Menu`] popup, [`ContextMenu`](crate::ContextMenu), or
648/// [`MenuBar`](crate::MenuBar) dropdown.
649///
650/// Add with `ui.add(MenuSection::new("Edit"))` between groups of related
651/// items. The header is non-interactive and renders in the muted text
652/// tone with extra top padding so it visually separates from the item
653/// above without needing a separator.
654///
655/// ```no_run
656/// # use elegance::{Menu, MenuItem, MenuSection};
657/// # egui::__run_test_ui(|ui| {
658/// # let trigger = ui.button("\u{22EF}");
659/// Menu::new("row_actions").show_below(&trigger, |ui| {
660/// ui.add(MenuItem::new("Copy").shortcut("\u{2318}C"));
661/// ui.separator();
662/// ui.add(MenuSection::new("Selection"));
663/// ui.add(MenuItem::new("Highlight matches").checked(true));
664/// ui.add(MenuItem::new("Show whitespace").checked(false));
665/// });
666/// # });
667/// ```
668#[must_use = "Add with `ui.add(...)`."]
669pub struct MenuSection {
670 label: WidgetText,
671}
672
673impl std::fmt::Debug for MenuSection {
674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 f.debug_struct("MenuSection")
676 .field("label", &self.label.text())
677 .finish()
678 }
679}
680
681impl MenuSection {
682 /// Create a section header with the given label. The text is
683 /// uppercased at render time.
684 pub fn new(label: impl Into<WidgetText>) -> Self {
685 Self {
686 label: label.into(),
687 }
688 }
689}
690
691impl Widget for MenuSection {
692 fn ui(self, ui: &mut Ui) -> Response {
693 let theme = Theme::current(ui.ctx());
694 let p = &theme.palette;
695 let t = &theme.typography;
696
697 let pad_x = 10.0;
698 let pad_top = 6.0;
699 let pad_bottom = 2.0;
700
701 let text = self.label.text().to_uppercase();
702 let galley = crate::theme::placeholder_galley(ui, &text, t.small, false, f32::INFINITY);
703
704 let desired = Vec2::new(
705 galley.size().x + pad_x * 2.0,
706 galley.size().y + pad_top + pad_bottom,
707 );
708 // Use `allocate_at_least` to inherit the parent menu's
709 // top-down-justified width so the row spans the popup's interior.
710 let (rect, response) = ui.allocate_at_least(desired, Sense::hover());
711
712 if ui.is_rect_visible(rect) {
713 let pos = Pos2::new(rect.min.x + pad_x, rect.min.y + pad_top);
714 ui.painter().galley(pos, galley, p.text_faint);
715 }
716
717 response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &text));
718 response
719 }
720}