Skip to main content

egui_charts/ui_kit/modal/
modal.rs

1//! Modal Widget
2//!
3//! A modal dialog in the dialog chrome, centred on the screen above a dimmed backdrop.
4
5use crate::localization as l10n;
6use egui::{Color32, Context, Id, Ui};
7
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10use crate::ui_kit::dialog::{dialog_frame, title_row};
11
12/// A modal dialog widget
13pub struct Modal {
14    id: Id,
15    title: String,
16    width: Option<f32>,
17    height: Option<f32>,
18    min_height: Option<f32>,
19    closable: bool,
20    dim_background: bool,
21    rounding: Option<f32>,
22    fill: Option<Color32>,
23    backdrop: Option<Color32>,
24    top: Option<f32>,
25}
26
27impl Modal {
28    /// Create a new modal with the given title. An empty title leaves the header to the content.
29    pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {
30        Self {
31            id: Id::unique(id),
32            title: title.into(),
33            width: None,
34            height: None,
35            min_height: None,
36            closable: true,
37            dim_background: true,
38            rounding: None,
39            fill: None,
40            backdrop: None,
41            top: None,
42        }
43    }
44
45    /// Set the outer width of the modal, including its frame padding
46    pub fn width(mut self, width: f32) -> Self {
47        self.width = Some(width);
48        self
49    }
50
51    /// Set the outer height of the modal, including its 16 px padding, for content that fills
52    /// the modal, such as a list in a scroll area. The modal keeps 24 px free above and below it
53    /// in the window and follows the window when it shrinks. Overrides [`Self::min_height`].
54    pub fn height(mut self, height: f32) -> Self {
55        self.height = Some(height);
56        self
57    }
58
59    /// Set the minimum outer height of the modal, including its 16 px padding
60    pub fn min_height(mut self, height: f32) -> Self {
61        self.min_height = Some(height);
62        self
63    }
64
65    /// Set whether the modal can be closed with the close button, Escape or a backdrop click
66    pub fn closable(mut self, closable: bool) -> Self {
67        self.closable = closable;
68        self
69    }
70
71    /// Override the surface fill, leaving the theme's panel fill as the default.
72    pub fn fill(mut self, fill: Color32) -> Self {
73        self.fill = Some(fill);
74        self
75    }
76
77    /// Override the backdrop colour, leaving the theme's modal overlay as the default.
78    pub fn backdrop(mut self, color: Color32) -> Self {
79        self.backdrop = Some(color);
80        self
81    }
82
83    /// Override the corner radius, leaving the theme's radius as the default.
84    pub fn rounding(mut self, rounding: f32) -> Self {
85        self.rounding = Some(rounding);
86        self
87    }
88
89    /// Keep a fixed top edge while content height changes; clamp it inside the window.
90    pub fn top(mut self, top: f32) -> Self {
91        self.top = Some(top);
92        self
93    }
94
95    /// Set whether to dim the background
96    pub fn dim_background(mut self, dim: bool) -> Self {
97        self.dim_background = dim;
98        self
99    }
100
101    /// Show the modal
102    ///
103    /// Returns `Some(R)` if the modal was shown, where R is the result of the content function.
104    /// The `open` parameter controls whether the modal is visible and can be set to `false`
105    /// to close the modal.
106    pub fn show<R>(
107        self,
108        ctx: &Context,
109        open: &mut bool,
110        add_contents: impl FnOnce(&mut Ui) -> R,
111    ) -> Option<ModalResponse<R>> {
112        if !*open {
113            return None;
114        }
115
116        let mut frame = dialog_frame(ctx);
117        if let Some(fill) = self.fill {
118            frame = frame.fill(fill);
119        }
120        if let Some(rounding) = self.rounding {
121            frame = frame.corner_radius(rounding);
122        }
123        let screen_height =
124            (ctx.content_rect().height() - 2.0 * ctx.active_theme().spacing.xxxl).max(0.0);
125        let height = self.height.map(|height| height.min(screen_height));
126        let size = egui::vec2(
127            self.width
128                .unwrap_or(DESIGN_TOKENS.sizing.dialog.default_width)
129                .min((ctx.content_rect().width() - 48.0).max(0.0)),
130            height
131                .or(self.min_height)
132                .unwrap_or(DESIGN_TOKENS.sizing.dialog.default_min_height)
133                .min(screen_height),
134        ) - frame.total_margin().sum();
135        let size = size.max(egui::Vec2::ZERO);
136        let max_height = (height.unwrap_or(screen_height) - frame.total_margin().sum().y).max(0.0);
137        let backdrop = if self.dim_background {
138            self.backdrop.unwrap_or(ctx.active_theme().ui.modal_overlay)
139        } else {
140            Color32::TRANSPARENT
141        };
142
143        let mut close_clicked = false;
144        let mut modal = egui::Modal::new(self.id);
145        if let Some(top) = self.top {
146            let screen = ctx.content_rect();
147            let top = top.clamp(
148                screen.top() + 24.0,
149                (screen.bottom() - height.unwrap_or(0.0) - 24.0).max(screen.top() + 24.0),
150            );
151            modal = modal.area(
152                egui::Area::new(self.id)
153                    .order(egui::Order::Foreground)
154                    .pivot(egui::Align2::CENTER_TOP)
155                    .fixed_pos(egui::pos2(screen.center().x, top)),
156            );
157        }
158        let response = modal.frame(frame).backdrop_color(backdrop).show(ctx, |ui| {
159            ui.set_width(size.x);
160            ui.set_min_height(size.y);
161            // Start from the window cap each frame, so scrolling content can grow and shrink.
162            ui.set_max_height(max_height);
163            if !self.title.is_empty() {
164                close_clicked = title_row(ui, &self.title, self.closable);
165            }
166            add_contents(ui)
167        });
168
169        if !self.title.is_empty() {
170            let translated_label = l10n::text(ctx, &self.title);
171            response.response.widget_info(|| {
172                egui::WidgetInfo::labeled(egui::Role::Dialog, true, &translated_label)
173            });
174            ctx.accesskit_node_builder(response.response.id, |node| node.set_modal());
175        }
176
177        // `should_close` covers Escape and a click on the backdrop.
178        let close_requested = self.closable && (close_clicked || response.should_close());
179        if close_requested {
180            *open = false;
181        }
182
183        Some(ModalResponse {
184            inner: response.inner,
185            close_requested,
186        })
187    }
188}
189
190/// Response from showing a Modal
191pub struct ModalResponse<R> {
192    /// The result of the content function
193    pub inner: R,
194    /// Whether the modal was requested to close
195    pub close_requested: bool,
196}
197
198/// A simple modal that manages its own open state
199pub struct SimpleModal {
200    modal: Modal,
201    open: bool,
202}
203
204impl SimpleModal {
205    /// Create a new simple modal
206    pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {
207        Self {
208            modal: Modal::new(id, title),
209            open: false,
210        }
211    }
212
213    /// Set the width
214    pub fn width(mut self, width: f32) -> Self {
215        self.modal = self.modal.width(width);
216        self
217    }
218
219    /// Set closable
220    pub fn closable(mut self, closable: bool) -> Self {
221        self.modal = self.modal.closable(closable);
222        self
223    }
224
225    /// Open the modal
226    pub fn open(&mut self) {
227        self.open = true;
228    }
229
230    /// Close the modal
231    pub fn close(&mut self) {
232        self.open = false;
233    }
234
235    /// Check if the modal is open
236    pub fn is_open(&self) -> bool {
237        self.open
238    }
239
240    /// Show the modal
241    pub fn show<R>(
242        &mut self,
243        ctx: &Context,
244        add_contents: impl FnOnce(&mut Ui) -> R,
245    ) -> Option<ModalResponse<R>> {
246        // Clone modal config to avoid borrowing issues
247        let modal = Modal::new(self.modal.id, &self.modal.title)
248            .width(
249                self.modal
250                    .width
251                    .unwrap_or(DESIGN_TOKENS.sizing.dialog.default_width),
252            )
253            .closable(self.modal.closable)
254            .dim_background(self.modal.dim_background);
255
256        modal.show(ctx, &mut self.open, add_contents)
257    }
258}
259
260/// Helper function to show a centered modal with standard styling
261pub fn show_modal<R>(
262    ctx: &Context,
263    id: impl std::hash::Hash + std::fmt::Debug,
264    title: impl Into<String>,
265    open: &mut bool,
266    add_contents: impl FnOnce(&mut Ui) -> R,
267) -> Option<ModalResponse<R>> {
268    Modal::new(id, title).show(ctx, open, add_contents)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use egui::{Pos2, RawInput, Rect, ScrollArea, Vec2};
275
276    #[test]
277    fn scrolling_modals_follow_the_window_and_shrinking_content() {
278        for fixed in [false, true] {
279            let ctx = Context::default();
280            let mut open = true;
281            let frame = |screen_height: f32, rows: usize, open: &mut bool| {
282                let mut area = Id::NULL;
283                let mut title_top = 0.0;
284                let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, screen_height));
285                for _ in 0..3 {
286                    ctx.run_ui(
287                        RawInput {
288                            screen_rect: Some(screen),
289                            ..Default::default()
290                        },
291                        |ui| {
292                            let modal = Modal::new("scroll", "Title").width(500.0);
293                            let modal = if fixed { modal.height(800.0) } else { modal };
294                            modal.show(ui.ctx(), open, |ui| {
295                                area = ui.layer_id().id;
296                                title_top = ui.min_rect().top();
297                                ScrollArea::vertical()
298                                    .max_height(ui.available_height())
299                                    .show(ui, |ui| {
300                                        for i in 0..rows {
301                                            ui.label(format!("Row {i}"));
302                                        }
303                                    });
304                            });
305                        },
306                    )
307                    .drop_without_applying_deltas();
308                }
309                let rect = ctx.memory(|m| m.area_rect(area)).unwrap();
310                assert!(
311                    rect.top() >= 23.0 && rect.bottom() <= screen_height - 23.0,
312                    "fixed {fixed}: {rect:?}"
313                );
314                assert!(title_top >= 24.0, "title above the window");
315                rect
316            };
317            let tall = frame(900.0, 100, &mut open);
318            assert!(tall.height() >= 790.0, "fixed {fixed}: {tall:?}");
319            let short = frame(450.0, 100, &mut open);
320            assert!(short.height() <= 403.0);
321            let small = frame(900.0, 3, &mut open);
322            if !fixed {
323                assert!(small.height() < 300.0, "{small:?}");
324            }
325        }
326    }
327}