egui_modal/
modal.rs

1use egui::{
2    emath::{Align, Align2},
3    epaint::{Color32, Pos2, Rounding},
4    Area, Button, Context, Id, Layout, Response, RichText, Sense, Ui, WidgetText, Window,
5};
6
7const ERROR_ICON_COLOR: Color32 = Color32::from_rgb(200, 90, 90);
8const INFO_ICON_COLOR: Color32 = Color32::from_rgb(150, 200, 210);
9const WARNING_ICON_COLOR: Color32 = Color32::from_rgb(230, 220, 140);
10const SUCCESS_ICON_COLOR: Color32 = Color32::from_rgb(140, 230, 140);
11
12const CAUTION_BUTTON_FILL: Color32 = Color32::from_rgb(87, 38, 34);
13const SUGGESTED_BUTTON_FILL: Color32 = Color32::from_rgb(33, 54, 84);
14const CAUTION_BUTTON_TEXT_COLOR: Color32 = Color32::from_rgb(242, 148, 148);
15const SUGGESTED_BUTTON_TEXT_COLOR: Color32 = Color32::from_rgb(141, 182, 242);
16
17const OVERLAY_COLOR: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 200);
18
19/// The different styles a modal button can take.
20pub enum ModalButtonStyle {
21    /// A normal [`egui`] button
22    None,
23    /// A button highlighted blue
24    Suggested,
25    /// A button highlighted red
26    Caution,
27}
28
29/// An icon. If used, it will be shown next to the body of
30/// the modal.
31#[derive(Clone, Default, PartialEq)]
32pub enum Icon {
33    #[default]
34    /// An info icon
35    Info,
36    /// A warning icon
37    Warning,
38    /// A success icon
39    Success,
40    /// An error icon
41    Error,
42    /// A custom icon. The first field in the tuple is
43    /// the text of the icon, and the second field is the
44    /// color.
45    Custom((String, Color32)),
46}
47
48impl std::fmt::Display for Icon {
49    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
50        match self {
51            Icon::Info => write!(f, "ℹ"),
52            Icon::Warning => write!(f, "⚠"),
53            Icon::Success => write!(f, "✔"),
54            Icon::Error => write!(f, "❗"),
55            Icon::Custom((icon_text, _)) => write!(f, "{icon_text}"),
56        }
57    }
58}
59
60#[derive(Clone, Default)]
61struct DialogData {
62    title: Option<String>,
63    body: Option<String>,
64    icon: Option<Icon>,
65}
66
67/// Used for constructing and opening a modal dialog. This can be used
68/// to both set the title/body/icon of the modal and open it as a one-time call
69/// (as opposed to a continous call in the update loop) at the same time.
70/// Make sure to call `DialogBuilder::open` to actually open the dialog.
71#[must_use = "use `DialogBuilder::open`"]
72pub struct DialogBuilder {
73    data: DialogData,
74    modal_id: Id,
75    ctx: Context,
76}
77
78#[derive(Clone)]
79enum ModalType {
80    Modal,
81    Dialog(DialogData),
82}
83
84#[derive(Clone)]
85/// Information about the current state of the modal. (Pretty empty
86/// right now but may be expanded upon in the future.)
87struct ModalState {
88    is_open: bool,
89    was_outside_clicked: bool,
90    modal_type: ModalType,
91    last_frame_height: Option<f32>,
92}
93
94#[derive(Clone, Debug)]
95/// Contains styling parameters for the modal, like body margin
96/// and button colors.
97pub struct ModalStyle {
98    /// The margin around the modal body. Only applies if using
99    /// [`.body()`]
100    pub body_margin: f32,
101    /// The margin around the container of the icon and body. Only
102    /// applies if using [`.frame()`]
103    pub frame_margin: f32,
104    /// The margin around the container of the icon. Only applies
105    /// if using [`.icon()`].
106    pub icon_margin: f32,
107    /// The size of any icons used in the modal
108    pub icon_size: f32,
109    /// The color of the overlay that dims the background
110    pub overlay_color: Color32,
111
112    /// The fill color for the caution button style
113    pub caution_button_fill: Color32,
114    /// The fill color for the suggested button style
115    pub suggested_button_fill: Color32,
116
117    /// The text color for the caution button style
118    pub caution_button_text_color: Color32,
119    /// The text color for the suggested button style
120    pub suggested_button_text_color: Color32,
121
122    /// The text of the acknowledgement button for dialogs
123    pub dialog_ok_text: String,
124
125    /// The color of the info icon
126    pub info_icon_color: Color32,
127    /// The color of the warning icon
128    pub warning_icon_color: Color32,
129    /// The color of the success icon
130    pub success_icon_color: Color32,
131    /// The color of the error icon
132    pub error_icon_color: Color32,
133
134    /// The default width of the modal
135    pub default_width: Option<f32>,
136    /// The default height of the modal
137    pub default_height: Option<f32>,
138
139    /// The alignment of text inside the body
140    pub body_alignment: Align,
141}
142
143impl ModalState {
144    fn load(ctx: &Context, id: Id) -> Self {
145        ctx.data_mut(|d| d.get_temp(id).unwrap_or_default())
146    }
147    fn save(self, ctx: &Context, id: Id) {
148        ctx.data_mut(|d| d.insert_temp(id, self))
149    }
150}
151
152impl Default for ModalState {
153    fn default() -> Self {
154        Self {
155            was_outside_clicked: false,
156            is_open: false,
157            modal_type: ModalType::Modal,
158            last_frame_height: None,
159        }
160    }
161}
162
163impl Default for ModalStyle {
164    fn default() -> Self {
165        Self {
166            body_margin: 5.,
167            icon_margin: 7.,
168            frame_margin: 2.,
169            icon_size: 30.,
170            overlay_color: OVERLAY_COLOR,
171
172            caution_button_fill: CAUTION_BUTTON_FILL,
173            suggested_button_fill: SUGGESTED_BUTTON_FILL,
174
175            caution_button_text_color: CAUTION_BUTTON_TEXT_COLOR,
176            suggested_button_text_color: SUGGESTED_BUTTON_TEXT_COLOR,
177
178            dialog_ok_text: "ok".to_string(),
179
180            info_icon_color: INFO_ICON_COLOR,
181            warning_icon_color: WARNING_ICON_COLOR,
182            success_icon_color: SUCCESS_ICON_COLOR,
183            error_icon_color: ERROR_ICON_COLOR,
184
185            default_height: None,
186            default_width: None,
187
188            body_alignment: Align::Min,
189        }
190    }
191}
192/// A [`Modal`] is created using [`Modal::new()`]. Make sure to use a `let` binding when
193/// using [`Modal::new()`] to ensure you can call things like [`Modal::open()`] later on.
194/// ```
195/// let modal = Modal::new(ctx, "my_modal");
196/// modal.show(|ui| {
197///     ui.label("Hello world!")
198/// });
199/// if ui.button("modal").clicked() {
200///     modal.open();
201/// }
202/// ```
203/// Helper functions are also available to use that help apply margins based on the modal's
204/// [`ModalStyle`]. They are not necessary to use, but may help reduce boilerplate.
205/// ```
206/// let other_modal = Modal::new(ctx, "another_modal");
207/// other_modal.show(|ui| {
208///     other_modal.frame(ui, |ui| {
209///         other_modal.body(ui, "Hello again, world!");
210///     });
211///     other_modal.buttons(ui, |ui| {
212///         other_modal.button(ui, "Close");
213///     });
214/// });
215/// if ui.button("open the other modal").clicked() {
216///     other_modal.open();
217/// }
218/// ```
219pub struct Modal {
220    close_on_outside_click: bool,
221    style: ModalStyle,
222    ctx: Context,
223    id: Id,
224    window_id: Id,
225}
226
227fn ui_with_margin<R>(ui: &mut Ui, margin: f32, add_contents: impl FnOnce(&mut Ui) -> R) {
228    egui::Frame::none()
229        .inner_margin(margin)
230        .show(ui, |ui| add_contents(ui));
231}
232
233impl Modal {
234    /// Creates a new [`Modal`]. Can use constructor functions like [`Modal::with_style`]
235    /// to modify upon creation.
236    pub fn new(ctx: &Context, id_source: impl std::fmt::Display) -> Self {
237        let self_id = Id::new(id_source.to_string());
238        Self {
239            window_id: self_id.with("window"),
240            id: self_id,
241            style: ModalStyle::default(),
242            ctx: ctx.clone(),
243            close_on_outside_click: false,
244        }
245    }
246
247    fn set_open_state(&self, is_open: bool) {
248        let mut modal_state = ModalState::load(&self.ctx, self.id);
249        modal_state.is_open = is_open;
250        modal_state.save(&self.ctx, self.id)
251    }
252
253    fn set_outside_clicked(&self, was_clicked: bool) {
254        let mut modal_state = ModalState::load(&self.ctx, self.id);
255        modal_state.was_outside_clicked = was_clicked;
256        modal_state.save(&self.ctx, self.id)
257    }
258
259    /// Was the outer overlay clicked this frame?
260    pub fn was_outside_clicked(&self) -> bool {
261        let modal_state = ModalState::load(&self.ctx, self.id);
262        modal_state.was_outside_clicked
263    }
264
265    /// Is the modal currently open?
266    pub fn is_open(&self) -> bool {
267        let modal_state = ModalState::load(&self.ctx, self.id);
268        modal_state.is_open
269    }
270
271    /// Open the modal; make it visible. The modal prevents user input to other parts of the
272    /// application.
273    ///
274    /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within
275    /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15)
276    pub fn open(&self) {
277        self.set_open_state(true)
278    }
279
280    /// Close the modal so that it is no longer visible, allowing input to flow back into
281    /// the application.
282    ///
283    /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within
284    /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15)
285    pub fn close(&self) {
286        self.set_open_state(false)
287    }
288
289    /// If set to `true`, the modal will close itself if the user clicks outside on the modal window
290    /// (onto the overlay).
291    pub fn with_close_on_outside_click(mut self, do_close_on_click_ouside: bool) -> Self {
292        self.close_on_outside_click = do_close_on_click_ouside;
293        self
294    }
295
296    /// Change the [`ModalStyle`] of the modal upon creation.
297    pub fn with_style(mut self, style: &ModalStyle) -> Self {
298        self.style = style.clone();
299        self
300    }
301
302    /// Helper function for styling the title of the modal.
303    /// ```
304    /// let modal = Modal::new(ctx, "modal");
305    /// modal.show(|ui| {
306    ///     modal.title(ui, "my title");
307    /// });
308    /// ```
309    pub fn title(&self, ui: &mut Ui, text: impl Into<RichText>) {
310        let text: RichText = text.into();
311        ui.vertical_centered(|ui| {
312            ui.heading(text);
313        });
314        ui.separator();
315    }
316
317    /// Helper function for styling the icon of the modal.
318    /// ```
319    /// let modal = Modal::new(ctx, "modal");
320    /// modal.show(|ui| {
321    ///     modal.frame(ui, |ui| {
322    ///         modal.icon(ui, Icon::Info);
323    ///     });
324    /// });
325    /// ```
326    pub fn icon(&self, ui: &mut Ui, icon: Icon) {
327        let color = match icon {
328            Icon::Info => self.style.info_icon_color,
329            Icon::Warning => self.style.warning_icon_color,
330            Icon::Success => self.style.success_icon_color,
331            Icon::Error => self.style.error_icon_color,
332            Icon::Custom((_, color)) => color,
333        };
334        let text = RichText::new(icon.to_string())
335            .color(color)
336            .size(self.style.icon_size);
337        ui_with_margin(ui, self.style.icon_margin, |ui| {
338            ui.add(egui::Label::new(text));
339        });
340    }
341
342    /// Helper function for styling the container the of body and icon.
343    /// ```
344    /// let modal = Modal::new(ctx, "modal");
345    /// modal.show(|ui| {
346    ///     modal.title(ui, "my title");
347    ///     modal.frame(ui, |ui| {
348    ///         // inner modal contents go here
349    ///     });
350    ///     modal.buttons(ui, |ui| {
351    ///         // button contents go here
352    ///     });
353    /// });
354    /// ```
355    pub fn frame<R>(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
356        let last_frame_height = ModalState::load(&self.ctx, self.id)
357            .last_frame_height
358            .unwrap_or_default();
359        let default_height = self.style.default_height.unwrap_or_default();
360        let space_height = ((default_height - last_frame_height) * 0.5).max(0.);
361        ui.with_layout(
362            Layout::top_down(Align::Center).with_cross_align(Align::Center),
363            |ui| {
364                ui_with_margin(ui, self.style.frame_margin, |ui| {
365                    if space_height > 0. {
366                        ui.add_space(space_height);
367                        add_contents(ui);
368                        ui.add_space(space_height);
369                    } else {
370                        add_contents(ui);
371                    }
372                })
373            },
374        );
375    }
376
377    /// Helper function that should be used when using a body and icon together.
378    /// ```
379    /// let modal = Modal::new(ctx, "modal");
380    /// modal.show(|ui| {
381    ///     modal.frame(ui, |ui| {
382    ///         modal.body_and_icon(ui, "my modal body", Icon::Warning);
383    ///     });
384    /// });
385    /// ```
386    pub fn body_and_icon(&self, ui: &mut Ui, text: impl Into<WidgetText>, icon: Icon) {
387        egui::Grid::new(self.id).num_columns(2).show(ui, |ui| {
388            self.icon(ui, icon);
389            self.body(ui, text);
390        });
391    }
392
393    /// Helper function for styling the body of the modal.
394    /// ```
395    /// let modal = Modal::new(ctx, "modal");
396    /// modal.show(|ui| {
397    ///     modal.frame(ui, |ui| {
398    ///         modal.body(ui, "my modal body");
399    ///     });
400    /// });
401    /// ```
402    pub fn body(&self, ui: &mut Ui, text: impl Into<WidgetText>) {
403        let text: WidgetText = text.into();
404        ui.with_layout(Layout::top_down(self.style.body_alignment), |ui| {
405            ui_with_margin(ui, self.style.body_margin, |ui| {
406                ui.label(text);
407            })
408        });
409    }
410
411    /// Helper function for styling the button container of the modal.
412    /// ```
413    /// let modal = Modal::new(ctx, "modal");
414    /// modal.show(|ui| {
415    ///     modal.buttons(ui, |ui| {
416    ///         modal.button(ui, "my modal button");
417    ///     });
418    /// });
419    /// ```
420    pub fn buttons<R>(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
421        ui.separator();
422        ui.with_layout(Layout::right_to_left(Align::Min), add_contents);
423    }
424
425    /// Helper function for creating a normal button for the modal.
426    /// Automatically closes the modal on click.
427    pub fn button(&self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
428        self.styled_button(ui, text, ModalButtonStyle::None)
429    }
430
431    /// Helper function for creating a "cautioned" button for the modal.
432    /// Automatically closes the modal on click.
433    pub fn caution_button(&self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
434        self.styled_button(ui, text, ModalButtonStyle::Caution)
435    }
436
437    /// Helper function for creating a "suggested" button for the modal.
438    /// Automatically closes the modal on click.
439    pub fn suggested_button(&self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
440        self.styled_button(ui, text, ModalButtonStyle::Suggested)
441    }
442
443    fn styled_button(
444        &self,
445        ui: &mut Ui,
446        text: impl Into<WidgetText>,
447        button_style: ModalButtonStyle,
448    ) -> Response {
449        let button = match button_style {
450            ModalButtonStyle::Suggested => {
451                let text: WidgetText = text.into().color(self.style.suggested_button_text_color);
452                Button::new(text).fill(self.style.suggested_button_fill)
453            }
454            ModalButtonStyle::Caution => {
455                let text: WidgetText = text.into().color(self.style.caution_button_text_color);
456                Button::new(text).fill(self.style.caution_button_fill)
457            }
458            ModalButtonStyle::None => Button::new(text.into()),
459        };
460
461        let response = ui.add(button);
462        if response.clicked() {
463            self.close()
464        }
465        response
466    }
467
468    /// The ui contained in this function will be shown within the modal window. The modal will only actually show
469    /// when [`Modal::open`] is used.
470    pub fn show<R>(&self, add_contents: impl FnOnce(&mut Ui) -> R) {
471        let mut modal_state = ModalState::load(&self.ctx, self.id);
472        self.set_outside_clicked(false);
473        if modal_state.is_open {
474            let ctx_clone = self.ctx.clone();
475            let area_resp = Area::new(self.id)
476                .interactable(true)
477                .fixed_pos(Pos2::ZERO)
478                .show(&self.ctx, |ui: &mut Ui| {
479                    let screen_rect = ui.ctx().input(|i| i.screen_rect);
480                    let area_response = ui.allocate_response(screen_rect.size(), Sense::click());
481                    // let current_focus = area_response.ctx.memory().focus().clone();
482                    // let top_layer = area_response.ctx.memory().layer_ids().last();
483                    // if let Some(focus) = current_focus {
484                    //     area_response.ctx.memory().surrender_focus(focus)
485                    // }
486                    if area_response.clicked() {
487                        self.set_outside_clicked(true);
488                        if self.close_on_outside_click {
489                            self.close();
490                        }
491                    }
492                    ui.painter()
493                        .rect_filled(screen_rect, Rounding::ZERO, self.style.overlay_color);
494                });
495
496            ctx_clone.move_to_top(area_resp.response.layer_id);
497
498            // the below lines of code addresses a weird problem where if the default_height changes, egui doesnt respond unless
499            // it's a different window id
500            let mut window_id = self
501                .style
502                .default_width
503                .map_or(self.window_id, |w| self.window_id.with(w.to_string()));
504
505            window_id = self
506                .style
507                .default_height
508                .map_or(window_id, |h| window_id.with(h.to_string()));
509
510            let mut window = Window::new("")
511                .id(window_id)
512                .open(&mut modal_state.is_open)
513                .title_bar(false)
514                .anchor(Align2::CENTER_CENTER, [0., 0.])
515                .resizable(false);
516
517            let recalculating_height =
518                self.style.default_height.is_some() && modal_state.last_frame_height.is_none();
519
520            if let Some(default_height) = self.style.default_height {
521                window = window.default_height(default_height);
522            }
523
524            if let Some(default_width) = self.style.default_width {
525                window = window.default_width(default_width);
526            }
527
528            let response = window.show(&ctx_clone, add_contents);
529
530            if let Some(inner_response) = response {
531                ctx_clone.move_to_top(inner_response.response.layer_id);
532                if recalculating_height {
533                    let mut modal_state = ModalState::load(&self.ctx, self.id);
534                    modal_state.last_frame_height = Some(inner_response.response.rect.height());
535                    modal_state.save(&self.ctx, self.id);
536                }
537            }
538        }
539    }
540
541    /// Open the modal as a dialog. This is a shorthand way of defining a [`Modal::show`] once,
542    /// for example, if a function returns an `Error`. This should be used in conjunction with
543    /// [`Modal::show_dialog`].
544    #[deprecated(since = "0.3.0", note = "use `Modal::dialog`")]
545    pub fn open_dialog(
546        &self,
547        title: Option<impl std::fmt::Display>,
548        body: Option<impl std::fmt::Display>,
549        icon: Option<Icon>,
550    ) {
551        let modal_data = DialogData {
552            title: title.map(|s| s.to_string()),
553            body: body.map(|s| s.to_string()),
554            icon,
555        };
556        let mut modal_state = ModalState::load(&self.ctx, self.id);
557        modal_state.modal_type = ModalType::Dialog(modal_data);
558        modal_state.is_open = true;
559        modal_state.save(&self.ctx, self.id);
560    }
561
562    /// Create a `DialogBuilder` for this modal. Make sure to use `DialogBuilder::open`
563    /// to open the dialog.
564    pub fn dialog(&self) -> DialogBuilder {
565        DialogBuilder {
566            data: DialogData::default(),
567            modal_id: self.id.clone(),
568            ctx: self.ctx.clone(),
569        }
570    }
571
572    /// Needed in order to use [`Modal::dialog`]. Make sure this is called every frame, as
573    /// it renders the necessary ui when using a modal as a dialog.
574    pub fn show_dialog(&mut self) {
575        let modal_state = ModalState::load(&self.ctx, self.id);
576        if let ModalType::Dialog(modal_data) = modal_state.modal_type {
577            self.close_on_outside_click = true;
578            self.show(|ui| {
579                if let Some(title) = modal_data.title {
580                    self.title(ui, title)
581                }
582                self.frame(ui, |ui| {
583                    if modal_data.body.is_none() {
584                        if let Some(icon) = modal_data.icon {
585                            self.icon(ui, icon)
586                        }
587                    } else if modal_data.icon.is_none() {
588                        if let Some(body) = modal_data.body {
589                            self.body(ui, body)
590                        }
591                    } else if modal_data.icon.is_some() && modal_data.icon.is_some() {
592                        self.body_and_icon(ui, modal_data.body.unwrap(), modal_data.icon.unwrap())
593                    }
594                });
595                self.buttons(ui, |ui| {
596                    ui.with_layout(Layout::top_down_justified(Align::Center), |ui| {
597                        self.button(ui, &self.style.dialog_ok_text)
598                    })
599                })
600            });
601        }
602    }
603}
604
605impl DialogBuilder {
606    /// Construct this dialog with the given title.
607    pub fn with_title(mut self, title: impl std::fmt::Display) -> Self {
608        self.data.title = Some(title.to_string());
609        self
610    }
611    /// Construct this dialog with the given body.
612    pub fn with_body(mut self, body: impl std::fmt::Display) -> Self {
613        self.data.body = Some(body.to_string());
614        self
615    }
616    /// Construct this dialog with the given icon.
617    pub fn with_icon(mut self, icon: Icon) -> Self {
618        self.data.icon = Some(icon);
619        self
620    }
621    /// Open the dialog.
622    ///
623    /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within
624    /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15)
625    pub fn open(self) {
626        let mut modal_state = ModalState::load(&self.ctx, self.modal_id);
627        modal_state.modal_type = ModalType::Dialog(self.data);
628        modal_state.is_open = true;
629        modal_state.save(&self.ctx, self.modal_id);
630    }
631}