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#[must_use = "Add with `ui.add(...)`."]
91pub struct MenuItem {
92    label: WidgetText,
93    shortcut: Option<String>,
94    danger: bool,
95    enabled: bool,
96}
97
98impl std::fmt::Debug for MenuItem {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("MenuItem")
101            .field("label", &self.label.text())
102            .field("shortcut", &self.shortcut)
103            .field("danger", &self.danger)
104            .field("enabled", &self.enabled)
105            .finish()
106    }
107}
108
109impl MenuItem {
110    /// Create a menu item with the given label.
111    pub fn new(label: impl Into<WidgetText>) -> Self {
112        Self {
113            label: label.into(),
114            shortcut: None,
115            danger: false,
116            enabled: true,
117        }
118    }
119
120    /// Display a keyboard-shortcut hint on the right (informational only —
121    /// the actual shortcut is not bound).
122    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
123        self.shortcut = Some(shortcut.into());
124        self
125    }
126
127    /// Render the item in the danger tone — red label, red hover highlight.
128    /// Use for destructive actions.
129    pub fn danger(mut self) -> Self {
130        self.danger = true;
131        self
132    }
133
134    /// Disable the item. Disabled items do not fire `clicked()` and render
135    /// with muted text.
136    pub fn enabled(mut self, enabled: bool) -> Self {
137        self.enabled = enabled;
138        self
139    }
140}
141
142impl Widget for MenuItem {
143    fn ui(self, ui: &mut Ui) -> Response {
144        let theme = Theme::current(ui.ctx());
145        let p = &theme.palette;
146        let t = &theme.typography;
147
148        let pad_x = 10.0;
149        let pad_y = 6.0;
150        let gap_x = 16.0;
151
152        let label_color = if !self.enabled {
153            p.text_faint
154        } else if self.danger {
155            p.danger
156        } else {
157            p.text
158        };
159
160        let label_galley =
161            crate::theme::placeholder_galley(ui, self.label.text(), t.body, false, f32::INFINITY);
162
163        let shortcut_galley = self
164            .shortcut
165            .as_deref()
166            .map(|s| crate::theme::placeholder_galley(ui, s, t.small, false, f32::INFINITY));
167
168        let content_w =
169            label_galley.size().x + shortcut_galley.as_ref().map_or(0.0, |g| g.size().x + gap_x);
170        let desired = Vec2::new(
171            ui.available_width().max(content_w + pad_x * 2.0),
172            label_galley.size().y.max(t.body) + pad_y * 2.0,
173        );
174
175        let sense = if self.enabled {
176            Sense::click()
177        } else {
178            Sense::hover()
179        };
180        let (rect, response) = ui.allocate_exact_size(desired, sense);
181
182        if ui.is_rect_visible(rect) {
183            let is_hovered = response.hovered() && self.enabled;
184            if is_hovered {
185                let bg = if self.danger {
186                    with_alpha(p.red, 40)
187                } else {
188                    with_alpha(p.sky, 28)
189                };
190                let radius = CornerRadius::same((theme.control_radius as u8).saturating_sub(2));
191                ui.painter().rect_filled(rect, radius, bg);
192            }
193
194            let label_pos = Pos2::new(
195                rect.min.x + pad_x,
196                rect.center().y - label_galley.size().y * 0.5,
197            );
198            ui.painter().galley(label_pos, label_galley, label_color);
199
200            if let Some(galley) = shortcut_galley {
201                let pos = Pos2::new(
202                    rect.max.x - pad_x - galley.size().x,
203                    rect.center().y - galley.size().y * 0.5,
204                );
205                let color = if !self.enabled {
206                    p.text_faint
207                } else if self.danger {
208                    with_alpha(p.danger, 200)
209                } else {
210                    p.text_muted
211                };
212                ui.painter().galley(pos, galley, color);
213            }
214        }
215
216        response.widget_info(|| {
217            WidgetInfo::labeled(WidgetType::Button, self.enabled, self.label.text())
218        });
219        response
220    }
221}