Skip to main content

egui_components/
menu.rs

1//! `Menu` — a dropdown / context menu of clickable items.
2//!
3//! Build the entries, then attach the menu to a trigger [`Response`] (for a
4//! dropdown) or show it as a context menu. [`show`](Menu::show) returns the
5//! index of the entry that was clicked this frame, if any.
6//!
7//! ```ignore
8//! let trigger = ui.add(sc::Button::secondary("Options ▾"));
9//! if let Some(i) = sc::Menu::new("opts")
10//!     .item("Rename")
11//!     .item("Duplicate")
12//!     .separator()
13//!     .danger_item("Delete")
14//!     .show(ui, &trigger)
15//! {
16//!     // handle click on entry `i`
17//! }
18//! ```
19
20use egui::{vec2, Frame, Id, Margin, Rect, Response, Sense, Ui};
21use egui_components_theme::{mix, Theme};
22
23use crate::icon::{paint_icon, IconKind};
24
25enum Entry {
26    Item {
27        label: String,
28        icon: Option<IconKind>,
29        shortcut: Option<String>,
30        disabled: bool,
31        danger: bool,
32    },
33    Separator,
34    Label(String),
35}
36
37pub struct Menu {
38    id: Id,
39    entries: Vec<Entry>,
40    width: f32,
41}
42
43impl Menu {
44    pub fn new(id_salt: impl std::hash::Hash) -> Self {
45        Self {
46            id: Id::new(id_salt),
47            entries: Vec::new(),
48            width: 200.0,
49        }
50    }
51
52    pub fn width(mut self, w: f32) -> Self {
53        self.width = w;
54        self
55    }
56
57    pub fn item(mut self, label: impl Into<String>) -> Self {
58        self.entries.push(Entry::Item {
59            label: label.into(),
60            icon: None,
61            shortcut: None,
62            disabled: false,
63            danger: false,
64        });
65        self
66    }
67
68    pub fn icon_item(mut self, icon: IconKind, label: impl Into<String>) -> Self {
69        self.entries.push(Entry::Item {
70            label: label.into(),
71            icon: Some(icon),
72            shortcut: None,
73            disabled: false,
74            danger: false,
75        });
76        self
77    }
78
79    pub fn danger_item(mut self, label: impl Into<String>) -> Self {
80        self.entries.push(Entry::Item {
81            label: label.into(),
82            icon: None,
83            shortcut: None,
84            disabled: false,
85            danger: true,
86        });
87        self
88    }
89
90    pub fn disabled_item(mut self, label: impl Into<String>) -> Self {
91        self.entries.push(Entry::Item {
92            label: label.into(),
93            icon: None,
94            shortcut: None,
95            disabled: true,
96            danger: false,
97        });
98        self
99    }
100
101    /// Add a keyboard-shortcut hint shown right-aligned on the last item.
102    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
103        if let Some(Entry::Item { shortcut, .. }) = self.entries.last_mut() {
104            *shortcut = Some(s.into());
105        }
106        self
107    }
108
109    pub fn separator(mut self) -> Self {
110        self.entries.push(Entry::Separator);
111        self
112    }
113
114    pub fn section_label(mut self, label: impl Into<String>) -> Self {
115        self.entries.push(Entry::Label(label.into()));
116        self
117    }
118
119    /// Show as a dropdown anchored to `trigger`, toggled by its clicks.
120    /// Returns the clicked entry index, if any.
121    pub fn show(self, ui: &mut Ui, trigger: &Response) -> Option<usize> {
122        let popup = egui::Popup::from_toggle_button_response(trigger)
123            .id(ui.make_persistent_id(self.id).with("popup"))
124            .close_behavior(egui::PopupCloseBehavior::CloseOnClick);
125        self.render(ui, popup)
126    }
127
128    /// Show as a context menu (right-click `trigger`). Returns the clicked
129    /// entry index, if any.
130    pub fn context_menu(self, ui: &mut Ui, trigger: &Response) -> Option<usize> {
131        let popup = egui::Popup::context_menu(trigger)
132            .id(ui.make_persistent_id(self.id).with("ctx"))
133            .close_behavior(egui::PopupCloseBehavior::CloseOnClick);
134        self.render(ui, popup)
135    }
136
137    fn render(self, ui: &mut Ui, popup: egui::Popup<'_>) -> Option<usize> {
138        let theme = Theme::get(ui.ctx());
139        let c = theme.colors;
140        let width = self.width;
141
142        let frame = Frame::new()
143            .fill(c.popover_background)
144            .stroke(theme.border_stroke())
145            .corner_radius(theme.corner())
146            .inner_margin(Margin::same(4))
147            .shadow(egui::epaint::Shadow {
148                offset: [0, 4],
149                blur: 16,
150                spread: 0,
151                color: c.overlay,
152            });
153
154        let entries = self.entries;
155        popup
156            .width(width)
157            .gap(4.0)
158            .frame(frame)
159            .show(|ui| {
160                ui.set_width(width - 8.0);
161                let mut clicked = None;
162                for (i, entry) in entries.iter().enumerate() {
163                    match entry {
164                        Entry::Separator => {
165                            ui.add_space(2.0);
166                            ui.add(crate::separator::Separator::horizontal());
167                            ui.add_space(2.0);
168                        }
169                        Entry::Label(text) => {
170                            ui.add(crate::label::Label::new(text.clone()).muted().size(crate::common::Size::Small));
171                        }
172                        Entry::Item {
173                            label,
174                            icon,
175                            shortcut,
176                            disabled,
177                            danger,
178                        } => {
179                            if menu_item(ui, label, *icon, shortcut.as_deref(), *disabled, *danger) {
180                                clicked = Some(i);
181                            }
182                        }
183                    }
184                }
185                clicked
186            })
187            .and_then(|r| r.inner)
188    }
189}
190
191fn menu_item(
192    ui: &mut Ui,
193    label: &str,
194    icon: Option<IconKind>,
195    shortcut: Option<&str>,
196    disabled: bool,
197    danger: bool,
198) -> bool {
199    let theme = Theme::get(ui.ctx());
200    let c = theme.colors;
201    let m = theme.metrics;
202    let row_h = m.button_height_sm;
203    let pad_x = 8.0;
204    let icon_w = if icon.is_some() { 22.0 } else { 0.0 };
205
206    let sense = if disabled { Sense::hover() } else { Sense::click() };
207    let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), sense);
208
209    if ui.is_rect_visible(rect) {
210        let fg = if disabled {
211            mix(c.muted_foreground, c.background, 0.3)
212        } else if danger {
213            c.danger_background
214        } else {
215            c.foreground
216        };
217        let painter = ui.painter();
218        if !disabled && response.hovered() {
219            let bg = if danger {
220                mix(c.danger_background, c.background, 0.85)
221            } else {
222                c.accent_background
223            };
224            painter.rect_filled(rect, theme.corner_sm(), bg);
225        }
226        let mut x = rect.left() + pad_x;
227        if let Some(kind) = icon {
228            let ir = Rect::from_center_size(
229                egui::pos2(x + 8.0, rect.center().y),
230                vec2(16.0, 16.0),
231            );
232            paint_icon(painter, kind, ir, fg, 1.6);
233            x += icon_w;
234        }
235        painter.text(
236            egui::pos2(x, rect.center().y),
237            egui::Align2::LEFT_CENTER,
238            label,
239            egui::FontId::proportional(m.font_size_md),
240            fg,
241        );
242        if let Some(sc) = shortcut {
243            painter.text(
244                egui::pos2(rect.right() - pad_x, rect.center().y),
245                egui::Align2::RIGHT_CENTER,
246                sc,
247                egui::FontId::proportional(m.font_size_sm),
248                c.muted_foreground,
249            );
250        }
251        if !disabled && response.hovered() {
252            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
253        }
254    }
255
256    response.clicked()
257}