egui-modal 0.4.0

a modal library for egui
Documentation
use egui::{
    emath::{Align, Align2},
    epaint::{Color32, Pos2, Rounding},
    Area, Button, Context, Id, Layout, Response, RichText, Sense, Ui, WidgetText, Window,
};

const ERROR_ICON_COLOR: Color32 = Color32::from_rgb(200, 90, 90);
const INFO_ICON_COLOR: Color32 = Color32::from_rgb(150, 200, 210);
const WARNING_ICON_COLOR: Color32 = Color32::from_rgb(230, 220, 140);
const SUCCESS_ICON_COLOR: Color32 = Color32::from_rgb(140, 230, 140);

const CAUTION_BUTTON_FILL: Color32 = Color32::from_rgb(87, 38, 34);
const SUGGESTED_BUTTON_FILL: Color32 = Color32::from_rgb(33, 54, 84);
const CAUTION_BUTTON_TEXT_COLOR: Color32 = Color32::from_rgb(242, 148, 148);
const SUGGESTED_BUTTON_TEXT_COLOR: Color32 = Color32::from_rgb(141, 182, 242);

const OVERLAY_COLOR: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 200);

/// The different styles a modal button can take.
pub enum ModalButtonStyle {
    /// A normal [`egui`] button
    None,
    /// A button highlighted blue
    Suggested,
    /// A button highlighted red
    Caution,
}

/// An icon. If used, it will be shown next to the body of
/// the modal.
#[derive(Clone, Default, PartialEq)]
pub enum Icon {
    #[default]
    /// An info icon
    Info,
    /// A warning icon
    Warning,
    /// A success icon
    Success,
    /// An error icon
    Error,
    /// A custom icon. The first field in the tuple is
    /// the text of the icon, and the second field is the
    /// color.
    Custom((String, Color32)),
}

impl std::fmt::Display for Icon {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Icon::Info => write!(f, ""),
            Icon::Warning => write!(f, ""),
            Icon::Success => write!(f, ""),
            Icon::Error => write!(f, ""),
            Icon::Custom((icon_text, _)) => write!(f, "{icon_text}"),
        }
    }
}

#[derive(Clone, Default)]
struct DialogData {
    title: Option<String>,
    body: Option<String>,
    icon: Option<Icon>,
}

/// Used for constructing and opening a modal dialog. This can be used
/// to both set the title/body/icon of the modal and open it as a one-time call
/// (as opposed to a continous call in the update loop) at the same time.
/// Make sure to call `DialogBuilder::open` to actually open the dialog.
#[must_use = "use `DialogBuilder::open`"]
pub struct DialogBuilder {
    data: DialogData,
    modal_id: Id,
    ctx: Context,
}

#[derive(Clone)]
enum ModalType {
    Modal,
    Dialog(DialogData),
}

#[derive(Clone)]
/// Information about the current state of the modal. (Pretty empty
/// right now but may be expanded upon in the future.)
struct ModalState {
    is_open: bool,
    was_outside_clicked: bool,
    modal_type: ModalType,
    last_frame_height: Option<f32>,
}

#[derive(Clone, Debug)]
/// Contains styling parameters for the modal, like body margin
/// and button colors.
pub struct ModalStyle {
    /// The margin around the modal body. Only applies if using
    /// [`.body()`]
    pub body_margin: f32,
    /// The margin around the container of the icon and body. Only
    /// applies if using [`.frame()`]
    pub frame_margin: f32,
    /// The margin around the container of the icon. Only applies
    /// if using [`.icon()`].
    pub icon_margin: f32,
    /// The size of any icons used in the modal
    pub icon_size: f32,
    /// The color of the overlay that dims the background
    pub overlay_color: Color32,

    /// The fill color for the caution button style
    pub caution_button_fill: Color32,
    /// The fill color for the suggested button style
    pub suggested_button_fill: Color32,

    /// The text color for the caution button style
    pub caution_button_text_color: Color32,
    /// The text color for the suggested button style
    pub suggested_button_text_color: Color32,

    /// The text of the acknowledgement button for dialogs
    pub dialog_ok_text: String,

    /// The color of the info icon
    pub info_icon_color: Color32,
    /// The color of the warning icon
    pub warning_icon_color: Color32,
    /// The color of the success icon
    pub success_icon_color: Color32,
    /// The color of the error icon
    pub error_icon_color: Color32,

    /// The default width of the modal
    pub default_width: Option<f32>,
    /// The default height of the modal
    pub default_height: Option<f32>,

    /// The alignment of text inside the body
    pub body_alignment: Align,
}

impl ModalState {
    fn load(ctx: &Context, id: Id) -> Self {
        ctx.data_mut(|d| d.get_temp(id).unwrap_or_default())
    }
    fn save(self, ctx: &Context, id: Id) {
        ctx.data_mut(|d| d.insert_temp(id, self))
    }
}

impl Default for ModalState {
    fn default() -> Self {
        Self {
            was_outside_clicked: false,
            is_open: false,
            modal_type: ModalType::Modal,
            last_frame_height: None,
        }
    }
}

impl Default for ModalStyle {
    fn default() -> Self {
        Self {
            body_margin: 5.,
            icon_margin: 7.,
            frame_margin: 2.,
            icon_size: 30.,
            overlay_color: OVERLAY_COLOR,

            caution_button_fill: CAUTION_BUTTON_FILL,
            suggested_button_fill: SUGGESTED_BUTTON_FILL,

            caution_button_text_color: CAUTION_BUTTON_TEXT_COLOR,
            suggested_button_text_color: SUGGESTED_BUTTON_TEXT_COLOR,

            dialog_ok_text: "ok".to_string(),

            info_icon_color: INFO_ICON_COLOR,
            warning_icon_color: WARNING_ICON_COLOR,
            success_icon_color: SUCCESS_ICON_COLOR,
            error_icon_color: ERROR_ICON_COLOR,

            default_height: None,
            default_width: None,

            body_alignment: Align::Min,
        }
    }
}
/// A [`Modal`] is created using [`Modal::new()`]. Make sure to use a `let` binding when
/// using [`Modal::new()`] to ensure you can call things like [`Modal::open()`] later on.
/// ```
/// let modal = Modal::new(ctx, "my_modal");
/// modal.show(|ui| {
///     ui.label("Hello world!")
/// });
/// if ui.button("modal").clicked() {
///     modal.open();
/// }
/// ```
/// Helper functions are also available to use that help apply margins based on the modal's
/// [`ModalStyle`]. They are not necessary to use, but may help reduce boilerplate.
/// ```
/// let other_modal = Modal::new(ctx, "another_modal");
/// other_modal.show(|ui| {
///     other_modal.frame(ui, |ui| {
///         other_modal.body(ui, "Hello again, world!");
///     });
///     other_modal.buttons(ui, |ui| {
///         other_modal.button(ui, "Close");
///     });
/// });
/// if ui.button("open the other modal").clicked() {
///     other_modal.open();
/// }
/// ```
pub struct Modal {
    close_on_outside_click: bool,
    style: ModalStyle,
    ctx: Context,
    id: Id,
    window_id: Id,
}

fn ui_with_margin<R>(ui: &mut Ui, margin: f32, add_contents: impl FnOnce(&mut Ui) -> R) {
    egui::Frame::none()
        .inner_margin(margin)
        .show(ui, |ui| add_contents(ui));
}

impl Modal {
    /// Creates a new [`Modal`]. Can use constructor functions like [`Modal::with_style`]
    /// to modify upon creation.
    pub fn new(ctx: &Context, id_source: impl std::fmt::Display) -> Self {
        let self_id = Id::new(id_source.to_string());
        Self {
            window_id: self_id.with("window"),
            id: self_id,
            style: ModalStyle::default(),
            ctx: ctx.clone(),
            close_on_outside_click: false,
        }
    }

    fn set_open_state(&self, is_open: bool) {
        let mut modal_state = ModalState::load(&self.ctx, self.id);
        modal_state.is_open = is_open;
        modal_state.save(&self.ctx, self.id)
    }

    fn set_outside_clicked(&self, was_clicked: bool) {
        let mut modal_state = ModalState::load(&self.ctx, self.id);
        modal_state.was_outside_clicked = was_clicked;
        modal_state.save(&self.ctx, self.id)
    }

    /// Was the outer overlay clicked this frame?
    pub fn was_outside_clicked(&self) -> bool {
        let modal_state = ModalState::load(&self.ctx, self.id);
        modal_state.was_outside_clicked
    }

    /// Is the modal currently open?
    pub fn is_open(&self) -> bool {
        let modal_state = ModalState::load(&self.ctx, self.id);
        modal_state.is_open
    }

    /// Open the modal; make it visible. The modal prevents user input to other parts of the
    /// application.
    ///
    /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within
    /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15)
    pub fn open(&self) {
        self.set_open_state(true)
    }

    /// Close the modal so that it is no longer visible, allowing input to flow back into
    /// the application.
    ///
    /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within
    /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15)
    pub fn close(&self) {
        self.set_open_state(false)
    }

    /// If set to `true`, the modal will close itself if the user clicks outside on the modal window
    /// (onto the overlay).
    pub fn with_close_on_outside_click(mut self, do_close_on_click_ouside: bool) -> Self {
        self.close_on_outside_click = do_close_on_click_ouside;
        self
    }

    /// Change the [`ModalStyle`] of the modal upon creation.
    pub fn with_style(mut self, style: &ModalStyle) -> Self {
        self.style = style.clone();
        self
    }

    /// Helper function for styling the title of the modal.
    /// ```
    /// let modal = Modal::new(ctx, "modal");
    /// modal.show(|ui| {
    ///     modal.title(ui, "my title");
    /// });
    /// ```
    pub fn title(&self, ui: &mut Ui, text: impl Into<RichText>) {
        let text: RichText = text.into();
        ui.vertical_centered(|ui| {
            ui.heading(text);
        });
        ui.separator();
    }

    /// Helper function for styling the icon of the modal.
    /// ```
    /// let modal = Modal::new(ctx, "modal");
    /// modal.show(|ui| {
    ///     modal.frame(ui, |ui| {
    ///         modal.icon(ui, Icon::Info);
    ///     });
    /// });
    /// ```
    pub fn icon(&self, ui: &mut Ui, icon: Icon) {
        let color = match icon {
            Icon::Info => self.style.info_icon_color,
            Icon::Warning => self.style.warning_icon_color,
            Icon::Success => self.style.success_icon_color,
            Icon::Error => self.style.error_icon_color,
            Icon::Custom((_, color)) => color,
        };
        let text = RichText::new(icon.to_string())
            .color(color)
            .size(self.style.icon_size);
        ui_with_margin(ui, self.style.icon_margin, |ui| {
            ui.add(egui::Label::new(text));
        });
    }

    /// Helper function for styling the container the of body and icon.
    /// ```
    /// let modal = Modal::new(ctx, "modal");
    /// modal.show(|ui| {
    ///     modal.title(ui, "my title");
    ///     modal.frame(ui, |ui| {
    ///         // inner modal contents go here
    ///     });
    ///     modal.buttons(ui, |ui| {
    ///         // button contents go here
    ///     });
    /// });
    /// ```
    pub fn frame<R>(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
        let last_frame_height = ModalState::load(&self.ctx, self.id)
            .last_frame_height
            .unwrap_or_default();
        let default_height = self.style.default_height.unwrap_or_default();
        let space_height = ((default_height - last_frame_height) * 0.5).max(0.);
        ui.with_layout(
            Layout::top_down(Align::Center).with_cross_align(Align::Center),
            |ui| {
                ui_with_margin(ui, self.style.frame_margin, |ui| {
                    if space_height > 0. {
                        ui.add_space(space_height);
                        add_contents(ui);
                        ui.add_space(space_height);
                    } else {
                        add_contents(ui);
                    }
                })
            },
        );
    }

    /// Helper function that should be used when using a body and icon together.
    /// ```
    /// let modal = Modal::new(ctx, "modal");
    /// modal.show(|ui| {
    ///     modal.frame(ui, |ui| {
    ///         modal.body_and_icon(ui, "my modal body", Icon::Warning);
    ///     });
    /// });
    /// ```
    pub fn body_and_icon(&self, ui: &mut Ui, text: impl Into<WidgetText>, icon: Icon) {
        egui::Grid::new(self.id).num_columns(2).show(ui, |ui| {
            self.icon(ui, icon);
            self.body(ui, text);
        });
    }

    /// Helper function for styling the body of the modal.
    /// ```
    /// let modal = Modal::new(ctx, "modal");
    /// modal.show(|ui| {
    ///     modal.frame(ui, |ui| {
    ///         modal.body(ui, "my modal body");
    ///     });
    /// });
    /// ```
    pub fn body(&self, ui: &mut Ui, text: impl Into<WidgetText>) {
        let text: WidgetText = text.into();
        ui.with_layout(Layout::top_down(self.style.body_alignment), |ui| {
            ui_with_margin(ui, self.style.body_margin, |ui| {
                ui.label(text);
            })
        });
    }

    /// Helper function for styling the button container of the modal.
    /// ```
    /// let modal = Modal::new(ctx, "modal");
    /// modal.show(|ui| {
    ///     modal.buttons(ui, |ui| {
    ///         modal.button(ui, "my modal button");
    ///     });
    /// });
    /// ```
    pub fn buttons<R>(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
        ui.separator();
        ui.with_layout(Layout::right_to_left(Align::Min), add_contents);
    }

    /// Helper function for creating a normal button for the modal.
    /// Automatically closes the modal on click.
    pub fn button(&self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
        self.styled_button(ui, text, ModalButtonStyle::None)
    }

    /// Helper function for creating a "cautioned" button for the modal.
    /// Automatically closes the modal on click.
    pub fn caution_button(&self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
        self.styled_button(ui, text, ModalButtonStyle::Caution)
    }

    /// Helper function for creating a "suggested" button for the modal.
    /// Automatically closes the modal on click.
    pub fn suggested_button(&self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
        self.styled_button(ui, text, ModalButtonStyle::Suggested)
    }

    fn styled_button(
        &self,
        ui: &mut Ui,
        text: impl Into<WidgetText>,
        button_style: ModalButtonStyle,
    ) -> Response {
        let button = match button_style {
            ModalButtonStyle::Suggested => {
                let text: WidgetText = text.into().color(self.style.suggested_button_text_color);
                Button::new(text).fill(self.style.suggested_button_fill)
            }
            ModalButtonStyle::Caution => {
                let text: WidgetText = text.into().color(self.style.caution_button_text_color);
                Button::new(text).fill(self.style.caution_button_fill)
            }
            ModalButtonStyle::None => Button::new(text.into()),
        };

        let response = ui.add(button);
        if response.clicked() {
            self.close()
        }
        response
    }

    /// The ui contained in this function will be shown within the modal window. The modal will only actually show
    /// when [`Modal::open`] is used.
    pub fn show<R>(&self, add_contents: impl FnOnce(&mut Ui) -> R) {
        let mut modal_state = ModalState::load(&self.ctx, self.id);
        self.set_outside_clicked(false);
        if modal_state.is_open {
            let ctx_clone = self.ctx.clone();
            let area_resp = Area::new(self.id)
                .interactable(true)
                .fixed_pos(Pos2::ZERO)
                .show(&self.ctx, |ui: &mut Ui| {
                    let screen_rect = ui.ctx().input(|i| i.screen_rect);
                    let area_response = ui.allocate_response(screen_rect.size(), Sense::click());
                    // let current_focus = area_response.ctx.memory().focus().clone();
                    // let top_layer = area_response.ctx.memory().layer_ids().last();
                    // if let Some(focus) = current_focus {
                    //     area_response.ctx.memory().surrender_focus(focus)
                    // }
                    if area_response.clicked() {
                        self.set_outside_clicked(true);
                        if self.close_on_outside_click {
                            self.close();
                        }
                    }
                    ui.painter()
                        .rect_filled(screen_rect, Rounding::ZERO, self.style.overlay_color);
                });

            ctx_clone.move_to_top(area_resp.response.layer_id);

            // the below lines of code addresses a weird problem where if the default_height changes, egui doesnt respond unless
            // it's a different window id
            let mut window_id = self
                .style
                .default_width
                .map_or(self.window_id, |w| self.window_id.with(w.to_string()));

            window_id = self
                .style
                .default_height
                .map_or(window_id, |h| window_id.with(h.to_string()));

            let mut window = Window::new("")
                .id(window_id)
                .open(&mut modal_state.is_open)
                .title_bar(false)
                .anchor(Align2::CENTER_CENTER, [0., 0.])
                .resizable(false);

            let recalculating_height =
                self.style.default_height.is_some() && modal_state.last_frame_height.is_none();

            if let Some(default_height) = self.style.default_height {
                window = window.default_height(default_height);
            }

            if let Some(default_width) = self.style.default_width {
                window = window.default_width(default_width);
            }

            let response = window.show(&ctx_clone, add_contents);

            if let Some(inner_response) = response {
                ctx_clone.move_to_top(inner_response.response.layer_id);
                if recalculating_height {
                    let mut modal_state = ModalState::load(&self.ctx, self.id);
                    modal_state.last_frame_height = Some(inner_response.response.rect.height());
                    modal_state.save(&self.ctx, self.id);
                }
            }
        }
    }

    /// Open the modal as a dialog. This is a shorthand way of defining a [`Modal::show`] once,
    /// for example, if a function returns an `Error`. This should be used in conjunction with
    /// [`Modal::show_dialog`].
    #[deprecated(since = "0.3.0", note = "use `Modal::dialog`")]
    pub fn open_dialog(
        &self,
        title: Option<impl std::fmt::Display>,
        body: Option<impl std::fmt::Display>,
        icon: Option<Icon>,
    ) {
        let modal_data = DialogData {
            title: title.map(|s| s.to_string()),
            body: body.map(|s| s.to_string()),
            icon,
        };
        let mut modal_state = ModalState::load(&self.ctx, self.id);
        modal_state.modal_type = ModalType::Dialog(modal_data);
        modal_state.is_open = true;
        modal_state.save(&self.ctx, self.id);
    }

    /// Create a `DialogBuilder` for this modal. Make sure to use `DialogBuilder::open`
    /// to open the dialog.
    pub fn dialog(&self) -> DialogBuilder {
        DialogBuilder {
            data: DialogData::default(),
            modal_id: self.id.clone(),
            ctx: self.ctx.clone(),
        }
    }

    /// Needed in order to use [`Modal::dialog`]. Make sure this is called every frame, as
    /// it renders the necessary ui when using a modal as a dialog.
    pub fn show_dialog(&mut self) {
        let modal_state = ModalState::load(&self.ctx, self.id);
        if let ModalType::Dialog(modal_data) = modal_state.modal_type {
            self.close_on_outside_click = true;
            self.show(|ui| {
                if let Some(title) = modal_data.title {
                    self.title(ui, title)
                }
                self.frame(ui, |ui| {
                    if modal_data.body.is_none() {
                        if let Some(icon) = modal_data.icon {
                            self.icon(ui, icon)
                        }
                    } else if modal_data.icon.is_none() {
                        if let Some(body) = modal_data.body {
                            self.body(ui, body)
                        }
                    } else if modal_data.icon.is_some() && modal_data.icon.is_some() {
                        self.body_and_icon(ui, modal_data.body.unwrap(), modal_data.icon.unwrap())
                    }
                });
                self.buttons(ui, |ui| {
                    ui.with_layout(Layout::top_down_justified(Align::Center), |ui| {
                        self.button(ui, &self.style.dialog_ok_text)
                    })
                })
            });
        }
    }
}

impl DialogBuilder {
    /// Construct this dialog with the given title.
    pub fn with_title(mut self, title: impl std::fmt::Display) -> Self {
        self.data.title = Some(title.to_string());
        self
    }
    /// Construct this dialog with the given body.
    pub fn with_body(mut self, body: impl std::fmt::Display) -> Self {
        self.data.body = Some(body.to_string());
        self
    }
    /// Construct this dialog with the given icon.
    pub fn with_icon(mut self, icon: Icon) -> Self {
        self.data.icon = Some(icon);
        self
    }
    /// Open the dialog.
    ///
    /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within
    /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15)
    pub fn open(self) {
        let mut modal_state = ModalState::load(&self.ctx, self.modal_id);
        modal_state.modal_type = ModalType::Dialog(self.data);
        modal_state.is_open = true;
        modal_state.save(&self.ctx, self.modal_id);
    }
}