Skip to main content

egui_charts/ui_kit/dialog/
mod.rs

1//! Dialog chrome shared by every dialog in the crate.
2//!
3//! - `DialogFrame`: centred, non-modal window in the dialog frame
4//! - `dialog_header`: title row with a close button
5//! - `DialogFooter`: a rule above right-aligned action buttons, with optional left content
6//!
7//! The kit `Modal`, `ConfirmDialog` and `PromptDialog` use the same frame, header and footer
8//! over a scrim.
9
10use crate::localization as l10n;
11use crate::theme::ThemeExt;
12use egui::{
13    Align, Align2, Color32, Context, Frame, Layout, Response, RichText, Stroke, Ui, UiBuilder,
14    UiStackInfo, Vec2,
15};
16
17use crate::icons::icons;
18use crate::tokens::DESIGN_TOKENS;
19use crate::ui_kit::buttons::{ButtonSize, IconButton};
20
21/// Minimum width of a short footer action such as Ok.
22const FOOTER_BUTTON_MIN_WIDTH: f32 = 44.0;
23
24/// Marks the content Ui of a fixed-height [`DialogFrame`].
25const FIXED_HEIGHT_TAG: &str = "egui_charts::dialog_frame::fixed_height";
26
27/// The dialog surface, with room for its title and right-aligned footer actions.
28pub(crate) fn dialog_frame(ctx: &Context) -> Frame {
29    let theme = ctx.active_theme();
30    let mut frame = Frame::window(&ctx.global_style()).fill(ctx.global_style().visuals.panel_fill);
31    if !theme.dark {
32        frame.stroke = Stroke::NONE;
33        frame.shadow.blur = (2.0 * theme.spacing.sm) as u8;
34        frame.shadow.offset = [0, theme.spacing.xs as i8];
35    }
36    let stroke_width = frame.stroke.width;
37    frame.inner_margin(egui::Margin {
38        left: (theme.spacing.xxl + theme.spacing.sm - stroke_width) as i8,
39        right: (theme.spacing.xxl + theme.spacing.sm - DESIGN_TOKENS.stroke.hairline - stroke_width)
40            as i8,
41        top: (theme.spacing.xxl - stroke_width) as i8,
42        bottom: (theme.spacing.xxl - stroke_width) as i8,
43    })
44}
45
46/// A centered dialog window with standard styling.
47///
48/// The requested size is the preferred size of the content inside the frame padding: the content
49/// area is at least that large, grows with its content, and is never sized beyond the application
50/// window.
51///
52/// # Example
53/// ```
54/// use egui::Vec2;
55/// use egui_charts::ui_kit::dialog::{DialogFrame, dialog_header};
56///
57/// # egui::__run_test_ctx(|ctx| {
58/// # let mut open = true;
59/// DialogFrame::new("Series Settings", Vec2::new(600.0, 500.0))
60///     .show(ctx, |ui| {
61///         if dialog_header(ui, "Series Settings") {
62///             open = false;
63///         }
64///         // content...
65///     });
66/// # });
67/// ```
68pub struct DialogFrame {
69    id: String,
70    size: Vec2,
71    auto_height: bool,
72    rounding: Option<f32>,
73}
74
75impl DialogFrame {
76    /// Create a centered dialog with a preferred size.
77    pub fn new(id: impl Into<String>, size: Vec2) -> Self {
78        Self {
79            id: id.into(),
80            size,
81            auto_height: false,
82            rounding: None,
83        }
84    }
85
86    /// Create a centered dialog with a preferred width and auto-height.
87    pub fn with_width(id: impl Into<String>, width: f32) -> Self {
88        Self {
89            id: id.into(),
90            size: Vec2::new(width, 0.0),
91            auto_height: true,
92            rounding: None,
93        }
94    }
95
96    /// Override the corner radius (default: the theme's window radius)
97    pub fn rounding(mut self, r: f32) -> Self {
98        self.rounding = Some(r);
99        self
100    }
101
102    /// Show the dialog. The closure receives `&mut Ui` for the dialog content.
103    pub fn show(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui)) {
104        let mut frame = dialog_frame(ctx);
105        if let Some(r) = self.rounding {
106            frame = frame.corner_radius(r);
107        }
108        let max = ctx
109            .content_rect()
110            .shrink(ctx.active_theme().spacing.xxxl)
111            .size()
112            - frame.total_margin().sum();
113        let inner = self.size.min(max).max(Vec2::ZERO);
114
115        let window = egui::Window::new(&self.id)
116            .anchor(Align2::CENTER_CENTER, Vec2::ZERO)
117            .title_bar(false)
118            .collapsible(false)
119            .resizable(false)
120            .max_width((max.x + frame.total_margin().sum().x).max(0.0))
121            .frame(frame);
122        let window = if self.auto_height {
123            window
124                .default_width(inner.x)
125                .default_height((max.y + frame.total_margin().sum().y).max(0.0))
126                .max_height((max.y + frame.total_margin().sum().y).max(0.0))
127        } else {
128            window.fixed_size(inner)
129        };
130
131        let input_blocked = ctx.text_edit_focused() || egui::Popup::is_any_open(ctx);
132        let auto_height = self.auto_height;
133        window.show(ctx, |ui| {
134            let translated_label = l10n::text(ui.ctx(), &self.id);
135            ui.response().widget_info(|| {
136                egui::WidgetInfo::labeled(egui::Role::Dialog, ui.is_enabled(), &translated_label)
137            });
138            ui.data_mut(|d| {
139                d.insert_temp(ui.layer_id().id.with("dialog_input_blocked"), input_blocked)
140            });
141            // A non-resizable window shrinks to its content, so hold the preferred size here.
142            ui.set_min_size(inner);
143            ui.set_max_width(inner.x);
144            if auto_height {
145                ui.set_max_height(max.y.max(0.0));
146                add_contents(ui);
147            } else {
148                // The tag lets a `DialogFooter` drawn directly in this Ui sit at the bottom.
149                let info = UiStackInfo::default().with_tag(FIXED_HEIGHT_TAG);
150                ui.scope_builder(UiBuilder::new().ui_stack_info(info), add_contents);
151            }
152            let focused = ui.ctx().memory(|m| m.focused()).is_some();
153            ui.data_mut(|d| d.insert_temp(ui.layer_id().id.with("dialog_had_focus"), focused));
154        });
155    }
156}
157
158/// Standard dialog title row: left-aligned title and a close button centred on it.
159///
160/// Returns `true` on a close click or an unhandled Escape key.
161pub fn dialog_header(ui: &mut Ui, title: &str) -> bool {
162    let close = title_row(ui, title, true);
163    let had_focus = ui
164        .data(|d| d.get_temp::<bool>(ui.layer_id().id.with("dialog_had_focus")))
165        .unwrap_or(false);
166    close || (!had_focus && dialog_key(ui, egui::Key::Escape))
167}
168
169fn dialog_key(ui: &Ui, key: egui::Key) -> bool {
170    if ui
171        .data(|d| d.get_temp::<bool>(ui.layer_id().id.with("dialog_input_blocked")))
172        .unwrap_or(false)
173        || egui::Popup::is_any_open(ui.ctx())
174        || ui.ctx().any_popup_open()
175        || ui.ctx().top_layer_id() != Some(ui.layer_id())
176        || ui.ctx().memory(|m| m.focused()).is_some()
177    {
178        return false;
179    }
180    ui.input_mut(|input| {
181        let fresh = input.events.iter().any(|event| {
182            matches!(event,
183                egui::Event::Key { key: pressed, pressed: true, repeat: false, modifiers, .. }
184                if *pressed == key && modifiers.is_none()
185            )
186        });
187        fresh && input.consume_key(egui::Modifiers::NONE, key)
188    })
189}
190
191/// Title row shared with the modal dialogs, which hide the close button when not closable.
192pub(crate) fn title_row(ui: &mut Ui, title: &str, closable: bool) -> bool {
193    // Fix the row height before placing the title so the title centres on the button.
194    let row = Vec2::new(ui.available_width(), ButtonSize::MD.pixels());
195    // The close button first, so a long title is elided in the width left rather than widening
196    // the dialog past a narrow window.
197    let close = ui
198        .allocate_ui_with_layout(row, Layout::right_to_left(Align::Center), |ui| {
199            let close = closable
200                && IconButton::new(&icons::CLOSE)
201                    .icon_scale(1.0)
202                    .tooltip("Close")
203                    .show(ui)
204                    .clicked();
205            ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
206                ui.add(
207                    egui::Label::new(
208                        RichText::new(l10n::text(ui.ctx(), title))
209                            .strong()
210                            .size(ui.active_theme().text.dialog_title)
211                            .variation("wght", 600.0),
212                    )
213                    .truncate(),
214                );
215            });
216            close
217        })
218        .inner;
219    ui.add_space(ui.active_theme().spacing.sm);
220    close
221}
222
223/// Standard dialog footer with right-aligned action buttons.
224///
225/// Renders a rule, then a row with optional left content and, on the right, the secondary
226/// (outlined) button followed by the primary (filled) button. Shown directly in a
227/// [`DialogFrame`] content Ui, the footer sits at the bottom of the frame.
228///
229/// # Example
230/// ```
231/// # use egui_charts::ui_kit::dialog::DialogFooter;
232/// # egui::__run_test_ui(|ui| {
233/// let (ok, cancel) = DialogFooter::new("Ok")
234///     .secondary("Cancel")
235///     .show(ui);
236/// if ok { /* apply */ }
237/// if cancel { /* close */ }
238/// # });
239/// ```
240pub struct DialogFooter {
241    primary_label: String,
242    secondary_label: Option<String>,
243    primary_enabled: bool,
244    danger: bool,
245}
246
247impl DialogFooter {
248    /// Create a footer with a primary action label.
249    pub fn new(primary_label: impl Into<String>) -> Self {
250        Self {
251            primary_label: primary_label.into(),
252            secondary_label: None,
253            primary_enabled: true,
254            danger: false,
255        }
256    }
257
258    /// Add a secondary (cancel) button.
259    pub fn secondary(mut self, label: impl Into<String>) -> Self {
260        self.secondary_label = Some(label.into());
261        self
262    }
263
264    /// Enable/disable the primary button.
265    pub fn primary_enabled(mut self, enabled: bool) -> Self {
266        self.primary_enabled = enabled;
267        self
268    }
269
270    /// Mark the primary action as destructive: it is filled with the danger colour.
271    pub fn danger(mut self) -> Self {
272        self.danger = true;
273        self
274    }
275
276    /// Show the footer. Returns `(primary_clicked, secondary_clicked)`.
277    pub fn show(self, ui: &mut Ui) -> (bool, bool) {
278        self.show_with_left(ui, |_| ())
279    }
280
281    /// Show the footer with left-side content. Returns `(primary_clicked, secondary_clicked)`.
282    pub fn show_with_left<R>(
283        self,
284        ui: &mut Ui,
285        left_content: impl FnOnce(&mut Ui) -> R,
286    ) -> (bool, bool) {
287        // In a fixed-height `DialogFrame`, the free space goes between the content and the rule.
288        // The footer height is measured on the previous frame; the window's sizing pass measures
289        // it unpinned, so the first visible frame already has the window at its final size.
290        let pinned = ui.stack().tags().contains(FIXED_HEIGHT_TAG);
291        let height_id = ui.scope_id().with("dialog_footer_height");
292        if pinned && !ui.is_sizing_pass() {
293            let height = ui.data(|d| d.get_temp::<f32>(height_id)).unwrap_or(0.0);
294            ui.add_space((ui.available_height() - height).max(0.0));
295        }
296        let top = ui.cursor().top();
297
298        ui.add_space(ui.active_theme().spacing.lg);
299        ui.separator();
300        ui.add_space(ui.active_theme().spacing.lg);
301
302        let row = ui.horizontal(|ui| {
303            left_content(ui);
304
305            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
306                let theme = ui.active_theme();
307                let secondary_min_width = DESIGN_TOKENS.sizing.settings_dialog.button_min_width;
308                ui.spacing_mut().item_spacing.x = theme.spacing.xl;
309                ui.spacing_mut().button_padding.x = theme.spacing.xl + theme.spacing.xs;
310                let font = egui::TextStyle::Button.resolve(ui.style());
311                let width = std::iter::once((&self.primary_label, FOOTER_BUTTON_MIN_WIDTH))
312                    .chain(
313                        self.secondary_label
314                            .iter()
315                            .map(|label| (label, secondary_min_width)),
316                    )
317                    .map(|(label, min_width)| {
318                        (ui.painter()
319                            .layout_no_wrap(
320                                l10n::text(ui.ctx(), label.clone()),
321                                font.clone(),
322                                Color32::PLACEHOLDER,
323                            )
324                            .size()
325                            .x
326                            + 2.0 * ui.spacing().button_padding.x)
327                            .max(min_width)
328                    })
329                    .sum::<f32>()
330                    + if self.secondary_label.is_some() {
331                        theme.spacing.xl
332                    } else {
333                        0.0
334                    };
335                ui.allocate_ui_with_layout(
336                    Vec2::new(width, DESIGN_TOKENS.sizing.settings_dialog.button_height),
337                    Layout::left_to_right(Align::Center),
338                    |ui| {
339                        let colors = &theme.ui;
340                        let outline = ui.visuals().widgets.inactive.fg_stroke.color;
341                        let secondary_clicked =
342                            self.secondary_label.as_ref().is_some_and(|label| {
343                                let hover = ui.visuals().widgets.hovered.bg_fill;
344                                let fill = [Color32::TRANSPARENT, hover, hover];
345                                let stroke = Stroke::new(DESIGN_TOKENS.stroke.hairline, outline);
346                                footer_button(ui, label, secondary_min_width, fill, outline, stroke)
347                                    .clicked()
348                            });
349                        let (fill, text) = if self.danger {
350                            let fill = [
351                                colors.danger_bg,
352                                colors.danger_bg_hover,
353                                colors.danger_bg_active,
354                            ];
355                            (fill, colors.danger_text)
356                        } else {
357                            // The text colour rather than the accent, so the confirming action
358                            // has the strongest contrast in the dialog in every theme.
359                            let fill = [colors.text, colors.text_secondary, colors.text_muted];
360                            (fill, colors.panel_bg)
361                        };
362                        let primary_clicked = ui
363                            .add_enabled_ui(self.primary_enabled, |ui| {
364                                footer_button(
365                                    ui,
366                                    &self.primary_label,
367                                    FOOTER_BUTTON_MIN_WIDTH,
368                                    fill,
369                                    text,
370                                    Stroke::NONE,
371                                )
372                            })
373                            .inner
374                            .clicked();
375
376                        (primary_clicked, secondary_clicked)
377                    },
378                )
379                .inner
380            })
381            .inner
382        });
383
384        if pinned {
385            let height = row.response.rect.bottom() - top;
386            ui.data_mut(|d| d.insert_temp(height_id, height));
387        }
388        let (primary, secondary) = row.inner;
389        (
390            primary || (self.primary_enabled && dialog_key(ui, egui::Key::Enter)),
391            secondary,
392        )
393    }
394}
395
396/// A footer action button: `fill` at rest, hovered and pressed, with `text` and `stroke`.
397fn footer_button(
398    ui: &mut Ui,
399    label: &str,
400    min_width: f32,
401    fill: [Color32; 3],
402    text: Color32,
403    stroke: Stroke,
404) -> Response {
405    let radius = ui.active_theme().radius.md;
406    ui.scope(|ui| {
407        let widgets = &mut ui.visuals_mut().widgets;
408        for (visuals, fill) in [
409            (&mut widgets.inactive, fill[0]),
410            (&mut widgets.hovered, fill[1]),
411            (&mut widgets.active, fill[2]),
412        ] {
413            visuals.weak_bg_fill = fill;
414            visuals.bg_stroke = stroke;
415            visuals.fg_stroke.color = text;
416        }
417        let response = ui.add(
418            egui::Button::new(l10n::text(ui.ctx(), label))
419                .corner_radius(radius)
420                .min_size(Vec2::new(
421                    min_width,
422                    DESIGN_TOKENS.sizing.settings_dialog.button_height,
423                )),
424        );
425        crate::styles::focus::draw_focus_ring(ui, &response, radius);
426        response
427    })
428    .inner
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn frame_keyboard_dismisses_and_activates_only_an_enabled_primary() {
437        for (key, enabled, expected) in [
438            (egui::Key::Escape, true, (true, false)),
439            (egui::Key::Enter, true, (false, true)),
440            (egui::Key::Enter, false, (false, false)),
441        ] {
442            let ctx = Context::default();
443            let frame = |events| {
444                let mut result = (false, false);
445                ctx.run_ui(
446                    egui::RawInput {
447                        events,
448                        ..Default::default()
449                    },
450                    |ui| {
451                        DialogFrame::new("keys", Vec2::new(420.0, 240.0)).show(ui.ctx(), |ui| {
452                            result.0 = dialog_header(ui, "Keys");
453                            result.1 = DialogFooter::new("Apply")
454                                .primary_enabled(enabled)
455                                .show(ui)
456                                .0;
457                        });
458                    },
459                )
460                .drop_without_applying_deltas();
461                result
462            };
463            frame(vec![]);
464            frame(vec![]);
465            assert_eq!(
466                frame(vec![egui::Event::Key {
467                    key,
468                    physical_key: None,
469                    pressed: true,
470                    repeat: false,
471                    modifiers: egui::Modifiers::NONE,
472                }]),
473                expected
474            );
475        }
476    }
477
478    #[test]
479    fn footer_tab_order_follows_the_buttons_from_left_to_right() {
480        let ctx = Context::default();
481        crate::theme::apply_to_egui(&ctx, &crate::theme::Theme::light());
482        let frame = |events| {
483            ctx.run_ui(
484                egui::RawInput {
485                    events,
486                    ..Default::default()
487                },
488                |ui| {
489                    DialogFooter::new("Apply").secondary("Cancel").show(ui);
490                },
491            )
492            .drop_without_applying_deltas();
493            ctx.memory(|m| m.focused())
494                .and_then(|id| ctx.read_response(id))
495                .map(|r| r.rect)
496        };
497        frame(vec![]);
498        let tab = || egui::Event::Key {
499            key: egui::Key::Tab,
500            physical_key: None,
501            pressed: true,
502            repeat: false,
503            modifiers: egui::Modifiers::NONE,
504        };
505        let first = frame(vec![tab()]).unwrap();
506        let second = frame(vec![tab()]).unwrap();
507        assert_eq!(first.size(), Vec2::new(72.0, 34.0));
508        assert_eq!(second.height(), 34.0);
509        assert_eq!(second.left() - first.right(), 12.0);
510    }
511
512    #[test]
513    fn footer_sits_at_the_bottom_of_a_fixed_frame_from_the_first_visible_frame() {
514        let ctx = Context::default();
515        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, Vec2::new(1000.0, 800.0));
516        let frame = || {
517            let mut row = egui::Rect::NOTHING;
518            let mut window = egui::Id::NULL;
519            let input = egui::RawInput {
520                screen_rect: Some(screen),
521                ..Default::default()
522            };
523            ctx.run_ui(input, |ui| {
524                DialogFrame::new("d", Vec2::new(420.0, 240.0)).show(ui.ctx(), |ui| {
525                    window = ui.layer_id().id;
526                    ui.label("Body");
527                    DialogFooter::new("Apply")
528                        .secondary("Cancel")
529                        .show_with_left(ui, |ui| row = ui.max_rect());
530                });
531            })
532            .drop_without_applying_deltas();
533            (ctx.memory(|m| m.area_rect(window)).unwrap(), row)
534        };
535        frame(); // sizing pass
536        let first = frame();
537        let second = frame();
538        assert_eq!(
539            first.0, second.0,
540            "the window moved after its first visible frame"
541        );
542        let inner_bottom = second.0.bottom() - ctx.active_theme().spacing.xxl;
543        assert!(
544            (second.1.top() + DESIGN_TOKENS.sizing.settings_dialog.button_height - inner_bottom)
545                .abs()
546                <= 1.5
547        );
548    }
549
550    #[test]
551    fn auto_height_scroll_body_reaches_the_screen_cap() {
552        let ctx = Context::default();
553        for (width, height) in [(1440.0, 900.0), (320.0, 450.0), (1440.0, 900.0)] {
554            let mut window = egui::Id::NULL;
555            for _ in 0..3 {
556                ctx.run_ui(
557                    egui::RawInput {
558                        screen_rect: Some(egui::Rect::from_min_size(
559                            egui::Pos2::ZERO,
560                            Vec2::new(width, height),
561                        )),
562                        ..Default::default()
563                    },
564                    |ui| {
565                        DialogFrame::with_width("scroll", 500.0).show(ui.ctx(), |ui| {
566                            window = ui.layer_id().id;
567                            dialog_header(ui, "Scrolling body");
568                            egui::ScrollArea::vertical()
569                                .max_height(ui.available_height())
570                                .show(ui, |ui| {
571                                    for i in 0..100 {
572                                        ui.label(format!("Row {i}"));
573                                    }
574                                });
575                        });
576                    },
577                )
578                .drop_without_applying_deltas();
579            }
580            let rect = ctx.memory(|m| m.area_rect(window)).unwrap();
581            assert!((rect.height() - (height - 48.0)).abs() <= 2.0, "{rect:?}");
582            assert!(rect.top() >= 23.0 && rect.bottom() <= height - 23.0);
583            assert!(
584                rect.left() >= 23.0 && rect.right() <= width - 23.0,
585                "{rect:?}"
586            );
587        }
588    }
589}