1use super::ContextMenuAction;
2use crate::icons::Icon;
3use crate::localization as l10n;
4use crate::theme::Theme;
5use crate::tokens::DESIGN_TOKENS;
6use egui::text::{LayoutJob, TextWrapping};
7use egui::{
8 Color32, Context, FontId, Galley, Id, KeyboardShortcut, Pos2, Rect, Response, Sense, Stroke,
9 Ui, Vec2, WidgetInfo,
10};
11use std::sync::Arc;
12
13#[derive(Clone)]
21pub struct MenuItem<A = ContextMenuAction> {
22 pub label: String,
24 pub shortcut: Option<KeyboardShortcut>,
26 pub icon: Option<&'static Icon>,
28 pub action: A,
30 pub enabled: bool,
32 pub danger: bool,
34}
35
36impl<A> MenuItem<A> {
37 pub fn new(label: impl Into<String>, action: A) -> Self {
39 Self {
40 label: label.into(),
41 shortcut: None,
42 icon: None,
43 action,
44 enabled: true,
45 danger: false,
46 }
47 }
48
49 pub fn with_shortcut(mut self, shortcut: impl Into<Option<KeyboardShortcut>>) -> Self {
51 self.shortcut = shortcut.into();
52 self
53 }
54
55 pub fn with_icon(mut self, icon: &'static Icon) -> Self {
57 self.icon = Some(icon);
58 self
59 }
60
61 pub fn disabled(mut self) -> Self {
63 self.enabled = false;
64 self
65 }
66
67 pub fn danger(mut self) -> Self {
69 self.danger = true;
70 self
71 }
72}
73
74pub(crate) enum MenuEntry<A> {
76 Item(MenuItem<A>),
77 Submenu {
79 label: String,
80 icon: Option<&'static Icon>,
81 entries: Vec<MenuEntry<A>>,
82 },
83 Separator,
84}
85
86impl<A> MenuEntry<A> {
87 pub(crate) fn map<B>(self, tag: &impl Fn(A) -> B) -> MenuEntry<B> {
89 match self {
90 Self::Item(item) => MenuEntry::Item(MenuItem {
91 label: item.label,
92 shortcut: item.shortcut,
93 icon: item.icon,
94 action: tag(item.action),
95 enabled: item.enabled,
96 danger: item.danger,
97 }),
98 Self::Submenu {
99 label,
100 icon,
101 entries,
102 } => MenuEntry::Submenu {
103 label,
104 icon,
105 entries: entries.into_iter().map(|entry| entry.map(tag)).collect(),
106 },
107 Self::Separator => MenuEntry::Separator,
108 }
109 }
110}
111
112pub(crate) fn trim_separators<A>(entries: &mut Vec<MenuEntry<A>>) {
114 let mut previous_separator = true;
115 entries.retain(|entry| {
116 let separator = matches!(entry, MenuEntry::Separator);
117 let keep = !separator || !previous_separator;
118 previous_separator = separator;
119 keep
120 });
121 if matches!(entries.last(), Some(MenuEntry::Separator)) {
122 entries.pop();
123 }
124}
125
126pub(crate) const POINTER_GAP: f32 = 2.0;
128
129const MAX_LABEL_WIDTH: f32 = 300.0;
131
132const COLUMN_GAP: f32 = 24.0;
134
135pub(crate) fn show_menu<A: Clone>(
143 ctx: &Context,
144 id: Id,
145 theme: &Theme,
146 anchor: Pos2,
147 gap: f32,
148 entries: &[MenuEntry<A>],
149 just_opened: bool,
150) -> Option<Option<A>> {
151 if entries.is_empty() {
152 return Some(None);
153 }
154 if just_opened {
155 ctx.data_mut(|data| data.remove::<usize>(id.with("submenu")));
156 }
157 let bounds = ctx.content_rect().shrink(theme.spacing.lg);
158 let mut rects = Vec::new();
159 let picked = draw_menu(
160 ctx,
161 id,
162 theme,
163 bounds,
164 &|size| place(anchor, gap, size, bounds),
165 entries,
166 &mut rects,
167 );
168 if picked.is_some() {
169 return Some(picked);
170 }
171 let dismissed = !just_opened
172 && ctx.input(|i| {
173 let clicked = i.pointer.primary_clicked() || i.pointer.secondary_clicked();
174 let outside = i
175 .pointer
176 .interact_pos()
177 .is_some_and(|p| !rects.iter().any(|rect| rect.contains(p)));
178 (clicked && outside) || i.key_pressed(egui::Key::Escape)
179 });
180 dismissed.then_some(None)
181}
182
183fn draw_menu<A: Clone>(
186 ctx: &Context,
187 id: Id,
188 theme: &Theme,
189 bounds: Rect,
190 place: &dyn Fn(Vec2) -> Pos2,
191 entries: &[MenuEntry<A>],
192 rects: &mut Vec<Rect>,
193) -> Option<A> {
194 let t = &DESIGN_TOKENS.sizing.context_menu;
195 let frame = egui::Frame::menu(&ctx.global_style())
196 .inner_margin(theme.spacing.sm)
197 .fill(theme.ui.panel_bg_floating)
198 .stroke(egui::Stroke::new(
199 DESIGN_TOKENS.stroke.hairline,
200 theme.ui.border,
201 ))
202 .corner_radius(theme.radius.sm);
203 let chrome = frame.total_margin().sum();
204 let pad = t.item_padding_h - theme.spacing.sm;
206
207 let layout = |text: String, size: f32, max_width: f32| {
208 let mut job =
209 LayoutJob::simple_singleline(text, FontId::proportional(size), Color32::PLACEHOLDER);
210 job.wrap = TextWrapping::truncate_at_width(max_width);
211 ctx.fonts_mut(|f| f.layout_job(job))
212 };
213 let mut rows: Vec<_> = entries
215 .iter()
216 .map(|entry| match entry {
217 MenuEntry::Item(item) => Some((
218 layout(l10n::text(ctx, &item.label), t.font_size, MAX_LABEL_WIDTH),
219 item.shortcut.map(|s| {
220 layout(
221 l10n::text(ctx, ctx.format_shortcut(&s)),
222 t.shortcut_font_size,
223 f32::INFINITY,
224 )
225 }),
226 )),
227 MenuEntry::Submenu { label, .. } => Some((
228 layout(l10n::text(ctx, label), t.font_size, MAX_LABEL_WIDTH),
229 None,
230 )),
231 MenuEntry::Separator => None,
232 })
233 .collect();
234
235 let (label_width, mut trailing_width) = rows.iter().flatten().fold(
236 (0.0_f32, 0.0_f32),
237 |(widest, widest_trailing), (label, trailing)| {
238 let trailing = trailing.as_ref().map_or(0.0, |g| g.size().x);
239 (widest.max(label.size().x), widest_trailing.max(trailing))
240 },
241 );
242 if entries
243 .iter()
244 .any(|entry| matches!(entry, MenuEntry::Submenu { .. }))
245 {
246 trailing_width = trailing_width.max(t.submenu_arrow_size);
247 }
248 let trailing_column = if trailing_width > 0.0 {
249 COLUMN_GAP + trailing_width
250 } else {
251 0.0
252 };
253 let row_width =
254 (2.0 * pad + t.icon_width + label_width + trailing_column).max(t.min_width - chrome.x);
255 let height: f32 = entries
256 .iter()
257 .map(|entry| match entry {
258 MenuEntry::Item(_) | MenuEntry::Submenu { .. } => t.item_height,
259 MenuEntry::Separator => t.separator_height,
260 })
261 .sum();
262 let size = (Vec2::new(row_width, height) + chrome).min(bounds.size());
263 let row_width = (size.x - chrome.x).max(0.0);
264 let label_width = (row_width - 2.0 * pad - t.icon_width - trailing_column).max(0.0);
265 for (label, _) in rows.iter_mut().flatten() {
266 if label.size().x > label_width {
267 *label = layout(label.text().to_owned(), t.font_size, label_width);
268 }
269 }
270 let pos = place(size);
271 let menu_rect = Rect::from_min_size(pos, size);
272 rects.push(menu_rect);
273
274 let open_key = id.with("submenu");
275 let mut open = ctx.data(|data| data.get_temp::<usize>(open_key));
276 let mut open_row = None;
277 let mut picked = None;
278 egui::Area::new(id)
279 .fixed_pos(pos)
280 .order(egui::Order::Foreground)
281 .show(ctx, |ui| {
282 ui.set_min_size(size);
283 frame.show(ui, |ui| {
284 ui.spacing_mut().item_spacing = Vec2::ZERO;
285 egui::ScrollArea::vertical()
286 .max_height((size.y - chrome.y).max(0.0))
287 .show(ui, |ui| {
288 for (index, (entry, row)) in entries.iter().zip(rows).enumerate() {
289 match (entry, row) {
290 (MenuEntry::Item(item), Some(row)) => {
291 let response =
292 draw_item(ui, theme, item, row, row_width, pad, false);
293 if response.hovered() {
294 open = None;
295 }
296 if response.clicked() && item.enabled {
297 picked = Some(item.action.clone());
298 }
299 }
300 (MenuEntry::Submenu { label, icon, .. }, Some((galley, _))) => {
301 let mut item = MenuItem::new(label.clone(), ());
302 item.icon = *icon;
303 let response = draw_item(
304 ui,
305 theme,
306 &item,
307 (galley, None),
308 row_width,
309 pad,
310 open == Some(index),
311 );
312 if response.hovered() || response.clicked() {
313 open = Some(index);
314 }
315 if open == Some(index) {
316 open_row = Some(response.rect);
317 }
318 draw_chevron(ui, theme, response.rect, pad);
319 }
320 _ => draw_separator(ui, theme, row_width, pad),
321 }
322 }
323 });
324 });
325 });
326 ctx.data_mut(|data| match open {
327 Some(index) => {
328 data.insert_temp(open_key, index);
329 }
330 None => data.remove::<usize>(open_key),
331 });
332 if let (Some(index), Some(row)) = (open, open_row)
333 && let Some(MenuEntry::Submenu { entries, .. }) = entries.get(index)
334 && !entries.is_empty()
335 {
336 let margin = frame.total_margin().top;
337 let beside = |size: Vec2| {
338 let x = if menu_rect.right() + size.x <= bounds.right() {
339 menu_rect.right()
340 } else {
341 (menu_rect.left() - size.x).max(bounds.left())
342 };
343 let y = (row.top() - margin)
344 .min(bounds.bottom() - size.y)
345 .max(bounds.top());
346 Pos2::new(x, y)
347 };
348 picked = picked.or(draw_menu(
349 ctx,
350 id.with(index),
351 theme,
352 bounds,
353 &beside,
354 entries,
355 rects,
356 ));
357 }
358 picked
359}
360
361fn place(anchor: Pos2, gap: f32, size: Vec2, bounds: Rect) -> Pos2 {
366 let axis = |at: f32, len: f32, min: f32, max: f32| {
367 if at + gap + len <= max {
368 at + gap
369 } else if at - gap - len >= min {
370 at - gap - len
371 } else {
372 (max - len).max(min)
373 }
374 };
375 Pos2::new(
376 axis(anchor.x, size.x, bounds.min.x, bounds.max.x),
377 axis(anchor.y, size.y, bounds.min.y, bounds.max.y),
378 )
379}
380
381fn draw_item<A>(
383 ui: &mut Ui,
384 theme: &Theme,
385 item: &MenuItem<A>,
386 (label, trailing): (Arc<Galley>, Option<Arc<Galley>>),
387 width: f32,
388 pad: f32,
389 highlighted: bool,
390) -> Response {
391 let t = &DESIGN_TOKENS.sizing.context_menu;
392 let c = &theme.ui;
393 let (rect, response) = ui.allocate_exact_size(
394 Vec2::new(width, t.item_height),
395 if item.enabled {
396 Sense::click()
397 } else {
398 Sense::hover()
399 },
400 );
401 crate::styles::focus::draw_focus_ring(ui, &response, theme.radius.sm);
402 let needs_info = response.clicked()
403 || response.double_clicked()
404 || response.triple_clicked()
405 || response.gained_focus()
406 || response.changed()
407 || ui
408 .ctx()
409 .accesskit_node_builder(response.id, |_| ())
410 .is_some();
411 #[cfg(debug_assertions)]
412 let needs_info = needs_info || ui.ctx().global_style().debug.show_interactive_widgets;
413 if needs_info {
414 let label = l10n::lookup(ui.ctx(), &item.label);
416 response.widget_info(|| {
417 WidgetInfo::labeled(
418 egui::Role::MenuItem,
419 response.enabled() && item.enabled,
420 label.as_deref().unwrap_or(&item.label),
421 )
422 });
423 }
424
425 let hovered = item.enabled && (response.hovered() || highlighted);
426 let (text, icon, trailing_color) = if !item.enabled {
428 (c.text_disabled, c.text_disabled, c.text_disabled)
429 } else if hovered && item.danger {
430 (c.danger_text, c.danger_text, c.danger_text)
431 } else if item.danger {
432 (c.error, c.error, c.text_secondary)
433 } else {
434 (c.text, c.text, c.text_secondary)
435 };
436 if hovered {
437 let fill = if item.danger {
438 c.danger_bg
439 } else {
440 c.btn_bg_hover
441 };
442 ui.painter().rect_filled(rect, theme.radius.sm, fill);
443 }
444
445 let left = rect.min.x + pad;
446 if let Some(glyph) = item.icon {
447 let icon_rect = Rect::from_min_size(
448 Pos2::new(left, rect.center().y - t.icon_size / 2.0),
449 Vec2::splat(t.icon_size),
450 );
451 glyph
452 .as_image_tinted(icon_rect.size(), icon)
453 .paint_at(ui, icon_rect);
454 }
455 let label_pos = Pos2::new(left + t.icon_width, rect.center().y - label.size().y / 2.0);
456 ui.painter().galley(label_pos, label, text);
457 if let Some(trailing) = trailing {
458 let pos = Pos2::new(
459 rect.max.x - pad - trailing.size().x,
460 rect.center().y - trailing.size().y / 2.0,
461 );
462 ui.painter().galley(pos, trailing, trailing_color);
463 }
464
465 response
466}
467
468fn draw_chevron(ui: &Ui, theme: &Theme, row: Rect, pad: f32) {
470 let size = DESIGN_TOKENS.sizing.context_menu.submenu_arrow_size;
471 let tip = Pos2::new(row.max.x - pad, row.center().y);
472 ui.painter().add(egui::Shape::line(
473 vec![
474 tip + Vec2::new(-size / 2.0, -size / 2.0),
475 tip,
476 tip + Vec2::new(-size / 2.0, size / 2.0),
477 ],
478 Stroke::new(1.5, theme.ui.text_secondary),
479 ));
480}
481
482fn draw_separator(ui: &mut Ui, theme: &Theme, width: f32, pad: f32) {
483 let t = &DESIGN_TOKENS.sizing.context_menu;
484 let (rect, _) = ui.allocate_exact_size(Vec2::new(width, t.separator_height), Sense::hover());
485 ui.painter().hline(
486 rect.x_range().shrink(pad),
487 rect.center().y,
488 Stroke::new(t.separator_thickness, theme.ui.border),
489 );
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::config::KeyboardAction;
496
497 #[test]
498 fn narrow_menus_keep_labels_out_of_the_shortcut_column() {
499 for theme in [Theme::light(), Theme::dark()] {
500 let ctx = Context::default();
501 crate::theme::apply_to_egui(&ctx, &theme);
502 ctx.set_os(egui::os::OperatingSystem::Nix);
503 let entries = [MenuEntry::Item(
504 MenuItem::new("Add alert on VERY_LONG_SYMBOL at 123,456.78…", ())
505 .with_shortcut(KeyboardAction::ResetChart.shortcut()),
506 )];
507 for width in [800.0, 320.0] {
508 for pass in 0..2 {
509 let output = ctx.run_ui(
510 egui::RawInput {
511 screen_rect: Some(Rect::from_min_size(
512 Pos2::ZERO,
513 Vec2::new(width, 300.0),
514 )),
515 ..Default::default()
516 },
517 |ui| {
518 show_menu(
519 ui.ctx(),
520 Id::unique("narrow_menu"),
521 &theme,
522 Pos2::new(20.0, 20.0),
523 POINTER_GAP,
524 &entries,
525 true,
526 );
527 },
528 );
529 let text: Vec<_> = output
530 .shapes
531 .iter()
532 .filter_map(|shape| {
533 if let egui::Shape::Text(text) = &shape.shape {
534 Some(text.galley.rect.translate(text.pos.to_vec2()))
535 } else {
536 None
537 }
538 })
539 .collect();
540 if pass == 1 {
541 assert_eq!(text.len(), 2);
542 assert!(text[0].right() + COLUMN_GAP <= text[1].left() + 0.5);
543 assert!(text[1].right() <= width - theme.spacing.lg);
544 }
545 output.drop_without_applying_deltas();
546 }
547 }
548 }
549 }
550}