Skip to main content

egui_charts/ui/context_menu/
menu.rs

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// ============================================================================
14// MENU ITEM DEFINITION
15// ============================================================================
16
17/// One row of a context menu, generic over the action it resolves to.
18///
19/// The chart, series and drawing menus all build their rows from this type.
20#[derive(Clone)]
21pub struct MenuItem<A = ContextMenuAction> {
22    /// Text displayed by the component.
23    pub label: String,
24    /// Shown right-aligned, formatted for the platform with [`Context::format_shortcut`].
25    pub shortcut: Option<KeyboardShortcut>,
26    /// Glyph displayed by the component.
27    pub icon: Option<&'static Icon>,
28    /// Action returned when this entry is activated.
29    pub action: A,
30    /// Whether the entry can be activated.
31    pub enabled: bool,
32    /// Destructive rows paint in the theme's danger colours.
33    pub danger: bool,
34}
35
36impl<A> MenuItem<A> {
37    /// Create an enabled menu entry with a label and the action it returns.
38    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    /// Set the keyboard shortcut displayed beside the label.
50    pub fn with_shortcut(mut self, shortcut: impl Into<Option<KeyboardShortcut>>) -> Self {
51        self.shortcut = shortcut.into();
52        self
53    }
54
55    /// Set the entry's icon.
56    pub fn with_icon(mut self, icon: &'static Icon) -> Self {
57        self.icon = Some(icon);
58        self
59    }
60
61    /// Prevent this entry from being activated.
62    pub fn disabled(mut self) -> Self {
63        self.enabled = false;
64        self
65    }
66
67    /// Mark the row as destructive.
68    pub fn danger(mut self) -> Self {
69        self.danger = true;
70        self
71    }
72}
73
74/// Menu entry - an item, a submenu or a separator
75pub(crate) enum MenuEntry<A> {
76    Item(MenuItem<A>),
77    /// A row that opens its entries beside the menu while it is hovered, or once clicked.
78    Submenu {
79        label: String,
80        icon: Option<&'static Icon>,
81        entries: Vec<MenuEntry<A>>,
82    },
83    Separator,
84}
85
86impl<A> MenuEntry<A> {
87    /// The same entry with every action passed through `tag`.
88    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
112/// Remove empty sections after controls have been filtered out.
113pub(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
126/// Distance between the pointer and the corner a menu opens at.
127pub(crate) const POINTER_GAP: f32 = 2.0;
128
129/// Labels wider than this are elided with "…".
130const MAX_LABEL_WIDTH: f32 = 300.0;
131
132/// Space between the label column and the shortcut column.
133const COLUMN_GAP: f32 = 24.0;
134
135/// Show an open menu next to `anchor` and return `Some` on the frame it closes: with the picked
136/// action, or with `None` when a click outside or Escape dismissed it.
137///
138/// The menu opens `gap` px right of and below the anchor and flips to the other side of the
139/// anchor on an axis where it would leave the window. A submenu opens beside its row, on the
140/// side with room. `just_opened` skips the dismiss check on the frame of the click that opened
141/// it.
142pub(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
183/// Draw one menu at the corner `place` gives for its size, then its open submenu, and return
184/// the picked action. Every drawn menu adds its rect to `rects`.
185fn 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    // Icons, labels and shortcuts keep `item_padding_h` from the menu edge.
205    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    // Each item's label and its shortcut.
214    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
361/// The corner of a menu of `size`, `gap` px from `anchor` and inside `bounds`.
362///
363/// On each axis the menu goes after the anchor, or before it when it does not fit after; when
364/// it fits on neither side it is clamped into `bounds`.
365fn 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
381/// Draw one row, filled as hovered when `highlighted`, and return its response.
382fn 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        // egui holds its context lock while calling widget_info's accessibility closure.
415        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    // (label, icon, trailing text)
427    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
468/// The arrow at the end of a submenu row.
469fn 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}