1use 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 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 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 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}