Skip to main content

egui_charts/ui/drawing_toolbar/
submenu_builder.rs

1//! Builder framework for dropdown submenus, and the flyout every drawing toolbar menu shares.
2//!
3//! The toolbar shows each menu in one flyout (`show_flyout`): a popup anchored to the
4//! button that opened it, with the theme's menu frame, closing on an outside click or Escape.
5//! Its rows are drawn by `flyout_row`, so tool lists and utility menus look alike.
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use egui::Ui;
11//! use egui_charts::icons::icons;
12//! use egui_charts::ui::drawing_toolbar::{SubmenuBuilder, ToggleConfig};
13//!
14//! fn cursor_menu(ui: &mut Ui, feature_enabled: &mut bool) {
15//!     let action = SubmenuBuilder::new(ui)
16//!         .with_width(240.0)
17//!         .add_icon_item(&icons::CROSSHAIR, "Cross", "Cross cursor")
18//!         .add_icon_item(&icons::DOT, "Dot", "Dot cursor")
19//!         .add_toggle(ToggleConfig {
20//!             label: "Enable feature".to_string(),
21//!             value: feature_enabled,
22//!         })
23//!         .show();
24//!     if let Some(action) = action {
25//!         println!("{action:?}");
26//!     }
27//! }
28//! ```
29
30use super::actions::DrawingToolbarAction;
31use crate::icons::{Icon, icons};
32use crate::localization as l10n;
33use crate::styles::{focus::focus_ring_color, icons as icon_sizes};
34use crate::theme::ThemeExt;
35use crate::tokens::DESIGN_TOKENS;
36use crate::ui_kit::ext::UiExt;
37use egui::{
38    Align2, FontId, Frame, Id, Key, Margin, Modifiers, Popup, PopupCloseBehavior, Pos2, Rect,
39    RectAlign, Response, Role, ScrollArea, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo,
40};
41
42/// Height of a flyout row.
43const ROW_HEIGHT: f32 = 28.0;
44
45/// Builder for creating dropdown submenus with consistent styling.
46///
47/// This builder provides a fluent API for constructing submenus with icons,
48/// text items, separators, and toggles. [`show`](Self::show) draws the rows into the `Ui` it
49/// was created with; the drawing toolbar calls it inside its flyout.
50pub struct SubmenuBuilder<'a> {
51    ui: &'a mut Ui,
52    items: Vec<SubmenuItem>,
53    width: f32,
54    toggle: Option<ToggleConfig<'a>>,
55}
56
57/// A single item in a submenu
58#[derive(Default)]
59pub struct SubmenuItem {
60    /// Glyph displayed by the component.
61    pub icon: Option<&'static Icon>,
62    /// Text displayed by the component.
63    pub label: String,
64    /// Help text displayed on hover.
65    pub tooltip: String,
66    /// Draw the row in the accent fill.
67    pub is_sel: bool,
68    /// Whether the entry is marked as a favorite.
69    pub is_favorite: bool,
70    /// Show a check mark in the right column.
71    pub is_checked: bool,
72    /// Draw the row in the error colour, for an action that destroys data.
73    pub is_danger: bool,
74    /// Callback invoked when the row is clicked, optionally returning an action.
75    pub on_click: Option<Box<dyn Fn() -> Option<DrawingToolbarAction>>>,
76}
77
78/// Configuration for a toggle checkbox at the bottom of a submenu
79pub struct ToggleConfig<'a> {
80    /// Text displayed by the component.
81    pub label: String,
82    /// Mutable toggle state.
83    pub value: &'a mut bool,
84}
85
86impl<'a> SubmenuBuilder<'a> {
87    /// Create a new submenu builder
88    ///
89    /// # Arguments
90    ///
91    /// * `ui` - The `Ui` the rows are drawn into
92    pub fn new(ui: &'a mut Ui) -> Self {
93        Self {
94            ui,
95            items: Vec::new(),
96            width: DESIGN_TOKENS.sizing.drawing_toolbar_ext.submenu_width_xl,
97            toggle: None,
98        }
99    }
100
101    /// Set the minimum submenu width; a longer row widens it.
102    pub fn with_width(mut self, width: f32) -> Self {
103        self.width = width;
104        self
105    }
106
107    /// Add an item with icon, label, and tooltip
108    pub fn add_icon_item(
109        mut self,
110        icon: &'static Icon,
111        label: impl Into<String>,
112        tooltip: impl Into<String>,
113    ) -> Self {
114        self.items.push(SubmenuItem {
115            icon: Some(icon),
116            label: label.into(),
117            tooltip: tooltip.into(),
118            ..Default::default()
119        });
120        self
121    }
122
123    /// Add a text-only item (no icon)
124    pub fn add_text_item(mut self, label: impl Into<String>, tooltip: impl Into<String>) -> Self {
125        self.items.push(SubmenuItem {
126            label: label.into(),
127            tooltip: tooltip.into(),
128            ..Default::default()
129        });
130        self
131    }
132
133    /// Add a text-only item with an action callback
134    pub fn add_text_item_with_action<F>(
135        mut self,
136        label: impl Into<String>,
137        tooltip: impl Into<String>,
138        action: F,
139    ) -> Self
140    where
141        F: Fn() -> Option<DrawingToolbarAction> + 'static,
142    {
143        self.items.push(SubmenuItem {
144            label: label.into(),
145            tooltip: tooltip.into(),
146            on_click: Some(Box::new(action)),
147            ..Default::default()
148        });
149        self
150    }
151
152    /// Draw the item added last with `icon`.
153    #[must_use]
154    pub(super) fn icon(mut self, icon: &'static Icon) -> Self {
155        if let Some(item) = self.items.last_mut() {
156            item.icon = Some(icon);
157        }
158        self
159    }
160
161    /// Show a check mark on the item added last when `checked`.
162    #[must_use]
163    pub fn checked(mut self, checked: bool) -> Self {
164        if let Some(item) = self.items.last_mut() {
165            item.is_checked = checked;
166        }
167        self
168    }
169
170    /// Draw the item added last with `icon`, in the error colour.
171    #[must_use]
172    pub fn danger(mut self, icon: &'static Icon) -> Self {
173        if let Some(item) = self.items.last_mut() {
174            item.icon = Some(icon);
175            item.is_danger = true;
176        }
177        self
178    }
179
180    /// Add a toggle checkbox at the bottom of the submenu
181    pub fn add_toggle(mut self, config: ToggleConfig<'a>) -> Self {
182        self.toggle = Some(config);
183        self
184    }
185
186    /// Draw the rows and return the action of the one clicked
187    pub fn show(self) -> Option<DrawingToolbarAction> {
188        let Self {
189            ui,
190            items,
191            width,
192            mut toggle,
193        } = self;
194
195        let rows: Vec<FlyoutRow> = items.iter().map(FlyoutRow::from).collect();
196        let width = rows
197            .iter()
198            .map(|row| row_width(ui, row))
199            .fold(width, f32::max);
200        ui.set_min_width(width);
201
202        let mut result = None;
203        for (item, row) in items.iter().zip(rows) {
204            let response = flyout_row(ui, width, &row).row;
205            if response.clicked()
206                && let Some(on_click) = &item.on_click
207            {
208                result = on_click();
209            }
210            response.on_hover_text(l10n::text(ui.ctx(), &item.tooltip));
211        }
212
213        if let Some(toggle) = &mut toggle {
214            ui.spaced_separator();
215            ui.checkbox(toggle.value, l10n::text(ui.ctx(), &toggle.label));
216        }
217
218        result
219    }
220}
221
222/// One row of a flyout.
223#[derive(Default)]
224pub(super) struct FlyoutRow<'a> {
225    pub icon: Option<&'a Icon>,
226    pub label: &'a str,
227    /// Shortcut text, right aligned.
228    pub shortcut: Option<&'a str>,
229    /// Accent fill, with the text in the accent text colour.
230    pub selected: bool,
231    /// A check mark in the right column.
232    pub check: bool,
233    pub danger: bool,
234    /// A favourite star in the right column: `Some(is_favorite)`. A favourite always shows
235    /// it; any other row shows an outline star while the pointer is over the row.
236    pub star: Option<bool>,
237}
238
239impl<'a> From<&'a SubmenuItem> for FlyoutRow<'a> {
240    fn from(item: &'a SubmenuItem) -> Self {
241        Self {
242            icon: item.icon,
243            label: &item.label,
244            selected: item.is_sel,
245            check: item.is_checked,
246            danger: item.is_danger,
247            ..Default::default()
248        }
249    }
250}
251
252/// What a flyout row reports: the row, and its star when it has one.
253pub(super) struct RowResponse {
254    pub row: Response,
255    pub star: Option<Response>,
256}
257
258/// Gap between the columns of a row, and its padding at both ends.
259fn gap(ui: &Ui) -> f32 {
260    ui.active_theme().spacing.lg
261}
262
263/// The width a row needs: icon, label, shortcut and the right column, with a gap between
264/// each.
265pub(super) fn row_width(ui: &Ui, row: &FlyoutRow) -> f32 {
266    let gap = gap(ui);
267    let theme = ui.active_theme();
268    let text_width = |text: &str, size: f32| {
269        ui.painter()
270            .layout_no_wrap(
271                l10n::text(ui.ctx(), text),
272                FontId::proportional(size),
273                theme.ui.text,
274            )
275            .size()
276            .x
277    };
278    let icon = row.icon.map_or(0.0, |_| icon_sizes::SM + gap);
279    let shortcut = row
280        .shortcut
281        .map_or(0.0, |s| gap + text_width(s, theme.text.small));
282    gap + icon + text_width(row.label, theme.text.body) + shortcut + gap + icon_sizes::SM + gap
283}
284
285/// Draw one flyout row `width` wide and return its responses.
286pub(super) fn flyout_row(ui: &mut Ui, width: f32, row: &FlyoutRow) -> RowResponse {
287    let theme = ui.active_theme();
288    let gap = gap(ui);
289    let (rect, row_response) = ui.allocate_exact_size(Vec2::new(width, ROW_HEIGHT), Sense::click());
290    let translated_label = l10n::text(ui.ctx(), row.label);
291    row_response.widget_info(|| {
292        WidgetInfo::selected(
293            Role::MenuItem,
294            row_response.enabled(),
295            row.selected || row.check,
296            &translated_label,
297        )
298    });
299
300    let slot = |right_edge: f32| {
301        Rect::from_center_size(
302            Pos2::new(right_edge - icon_sizes::SM / 2.0, rect.center().y),
303            Vec2::splat(icon_sizes::SM),
304        )
305    };
306    let right_slot = slot(rect.right() - gap);
307    // The star answers its own clicks, so the row's click never selects the tool.
308    let star_response = row.star.map(|favorite| {
309        let response = ui.interact(right_slot, row_response.id.with("star"), Sense::click());
310        let translated_label =
311            l10n::format_text(ui.ctx(), "Favourite {0}", &[("0", row.label.to_string())]);
312        response.widget_info(|| {
313            WidgetInfo::selected(
314                Role::Button,
315                response.enabled(),
316                favorite,
317                &translated_label,
318            )
319        });
320        response
321    });
322    let hovered = row_response.hovered() || star_response.as_ref().is_some_and(Response::hovered);
323
324    if ui.is_rect_visible(rect) {
325        let radius = ui.active_theme().radius.sm;
326        let painter = ui.painter();
327        let fill = match (row.selected, hovered) {
328            (true, true) => Some(theme.ui.accent_hover),
329            (true, false) => Some(theme.ui.accent),
330            (false, true) => Some(ui.visuals().widgets.hovered.bg_fill),
331            (false, false) => None,
332        };
333        if let Some(fill) = fill {
334            painter.rect_filled(rect, radius, fill);
335        }
336        let (text, secondary) = if row.selected {
337            (theme.ui.accent_text, theme.ui.accent_text)
338        } else if row.danger {
339            (theme.ui.error, theme.ui.error)
340        } else {
341            (theme.ui.text, theme.ui.text_secondary)
342        };
343
344        let mut x = rect.left() + gap;
345        if let Some(icon) = row.icon {
346            let icon_rect = Rect::from_min_size(
347                Pos2::new(x, rect.center().y - icon_sizes::SM / 2.0),
348                Vec2::splat(icon_sizes::SM),
349            );
350            icon.as_image_tinted(icon_rect.size(), text)
351                .paint_at(ui, icon_rect);
352            x += icon_sizes::SM + gap;
353        }
354        ui.painter().text(
355            Pos2::new(x, rect.center().y),
356            Align2::LEFT_CENTER,
357            l10n::text(ui.ctx(), row.label),
358            FontId::proportional(theme.text.body),
359            text,
360        );
361        if let Some(shortcut) = row.shortcut {
362            ui.painter().text(
363                Pos2::new(right_slot.left() - gap, rect.center().y),
364                Align2::RIGHT_CENTER,
365                l10n::text(ui.ctx(), shortcut),
366                FontId::proportional(theme.text.small),
367                secondary,
368            );
369        }
370        match row.star {
371            Some(true) => star(ui, &icons::STAR_FILLED, right_slot, theme.ui.warning, row),
372            Some(false) if hovered => {
373                star(ui, &icons::STAR_EMPTY, right_slot, secondary, row);
374            }
375            _ if row.check => {
376                icons::CHECK
377                    .as_image_tinted(right_slot.size(), text)
378                    .paint_at(ui, right_slot);
379            }
380            _ => {}
381        }
382        if row_response.has_focus() {
383            let (ring, ring_rect) = if row.selected {
384                (
385                    theme.ui.accent_text,
386                    rect.shrink(DESIGN_TOKENS.stroke.thick),
387                )
388            } else {
389                (focus_ring_color(ui), rect)
390            };
391            ui.painter().rect_stroke(
392                ring_rect,
393                radius,
394                Stroke::new(DESIGN_TOKENS.stroke.thick, ring),
395                StrokeKind::Inside,
396            );
397        }
398    }
399
400    RowResponse {
401        row: row_response,
402        star: star_response,
403    }
404}
405
406/// Paint a favourite star; on a selected row it takes the accent text colour so it stays
407/// readable on the accent fill.
408fn star(ui: &Ui, icon: &Icon, rect: Rect, color: egui::Color32, row: &FlyoutRow) {
409    let color = if row.selected {
410        ui.active_theme().ui.accent_text
411    } else {
412        color
413    };
414    icon.as_image_tinted(rect.size(), color).paint_at(ui, rect);
415}
416
417/// A section header: small upper-case text with a gap above.
418pub(super) fn section_header(ui: &mut Ui, text: &str, width: f32) {
419    let theme = ui.active_theme();
420    let gap = gap(ui);
421    let (rect, _) = ui.allocate_exact_size(
422        Vec2::new(width, gap + theme.text.small + gap / 2.0),
423        Sense::hover(),
424    );
425    ui.painter().text(
426        Pos2::new(rect.left() + gap, rect.bottom() - gap / 2.0),
427        Align2::LEFT_BOTTOM,
428        l10n::text(ui.ctx(), text),
429        FontId::proportional(theme.text.small),
430        theme.ui.text_secondary,
431    );
432}
433
434/// The popup id of the flyout for `category`.
435pub(super) fn flyout_id(category: &str) -> Id {
436    Id::unique(("drawing_toolbar_flyout", category))
437}
438
439/// Show `content` in the flyout of `category`, right of `anchor` and moved up or down to stay
440/// `spacing.lg` inside the window. It scrolls when the window is shorter than its content, and
441/// closes on an outside click or Escape; the caller reads that with
442/// `Popup::is_id_open(ctx, flyout_id(category))`. Open it with `Popup::open_id` first.
443pub(super) fn show_flyout<R>(
444    ui: &Ui,
445    category: &str,
446    anchor: Rect,
447    content: impl FnOnce(&mut Ui) -> R,
448) -> Option<R> {
449    let ctx = ui.ctx();
450    let lg = ui.active_theme().spacing.lg;
451    let window = ctx.content_rect();
452    let max_height = window.height() - 2.0 * lg - ui.style().spacing.menu_margin.sum().y;
453    let popup = Popup::new(flyout_id(category), ctx.clone(), anchor, ui.layer_id())
454        .open_memory(None)
455        .align(RectAlign::RIGHT_START)
456        .align_alternatives(&[])
457        .gap(ui.active_theme().spacing.sm)
458        // With the outline, the row tiles sit 7 px inside the flyout's edge.
459        .frame(Frame::menu(ui.style()).inner_margin(Margin::symmetric(
460            ui.active_theme().spacing.md as i8,
461            ui.active_theme().spacing.sm as i8,
462        )))
463        .close_behavior(PopupCloseBehavior::CloseOnClickOutside);
464    // Known from the last frame: how far down the flyout reaches from the button's top.
465    let height = popup.get_expected_size().map_or(0.0, |size| size.y);
466    let top = anchor
467        .min
468        .y
469        .min(window.max.y - lg - height)
470        .max(window.min.y + lg);
471    let shown = popup
472        .anchor(anchor.translate(Vec2::new(0.0, top - anchor.min.y)))
473        .show(|ui| {
474            // Rows sit flush, so a row's pitch is its height.
475            ui.spacing_mut().item_spacing = Vec2::ZERO;
476            // An area offers 400 px by default; a tall list may use the window's height.
477            ui.set_max_height(max_height);
478            ScrollArea::vertical()
479                .max_height(max_height)
480                .show(ui, content)
481                .inner
482        })
483        .map(|response| response.inner);
484    // Escape closes the flyout and nothing else, so the chart keeps its drawing state.
485    if !Popup::is_id_open(ctx, flyout_id(category)) {
486        ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape));
487    }
488    shown
489}