Skip to main content

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}