diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e44ed4e188..ed6e1b18a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w ## Unreleased ### Added ⭐ +* Add support for modal dialogs. * Add horizontal scrolling support to `ScrollArea` and `Window` (opt-in). * `TextEdit::layouter`: Add custom text layout for e.g. syntax highlighting or WYSIWYG. * `Fonts::layout_job`: New text layout engine allowing mixing fonts, colors and styles, with underlining and strikethrough. diff --git a/egui/src/containers/mod.rs b/egui/src/containers/mod.rs index c8a981a678b..bcda6e5d807 100644 --- a/egui/src/containers/mod.rs +++ b/egui/src/containers/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod area; pub(crate) mod collapsing_header; mod combo_box; pub(crate) mod frame; +pub mod modal; pub mod panel; pub mod popup; pub(crate) mod resize; diff --git a/egui/src/containers/modal.rs b/egui/src/containers/modal.rs new file mode 100644 index 00000000000..15f37fce4bb --- /dev/null +++ b/egui/src/containers/modal.rs @@ -0,0 +1,174 @@ +//! Show modal dialog. + +use crate::*; + +// ---------------------------------------------------------------------------- + +/// Common modal state +/// +/// > A modal dialog is a dialog that appears on top of the main content and moves the system into a special mode requiring user interaction. This dialog disables the main content until the user explicitly interacts with the modal dialog. +/// > – [Modal & Nonmodal Dialogs: When (& When Not) to Use Them](https://www.nngroup.com/articles/modal-nonmodal-dialog/) +/// For this implementation, the above suggests copying the common state approach from [`MonoState`] +#[derive(Clone, Debug, Default)] +pub(crate) struct ModalMonoState { + /// The optional id the modal took focus from + previous_focused_id_opt: Option, + /// The id of the last modal shown to enforce modality + last_modal_id_opt: Option, +} + +impl ModalMonoState { + /// The id source of the default modal + pub const DEFAULT_MODAL_ID_SOURCE: &'static str = "__default_modal"; + + /// Construct an id for the default modal + pub fn get_default_modal_id() -> Id { + Id::new(Self::DEFAULT_MODAL_ID_SOURCE) + } + /// Construct an interceptor color for the default modal + pub fn get_default_modal_interceptor_color() -> Color32 { + Color32::from_rgba_unmultiplied(0, 0, 0, 144) + } +} + +// ---------------------------------------------------------------------------- + +/// Relinquish control of the modal. If the modal is showing, this must be called to show a new modal. +/// +/// - No id is required to relinquish modal control – to prevent the application from entering a state where it's irrevocably stuck in a mode. In other words, the application must therefore track/manage when/whether it's allowable to relinquish control. +/// - Does nothing if the modal was not showing. +/// - If some id-bearing widget was previously focused, this returns the id. +pub fn relinquish_modal(ctx: &CtxRef) -> Option { + let last_modal_id_opt: Option = ctx + .memory() + .data_temp + .get_or_default::() + .last_modal_id_opt; + + ctx.memory() + .data_temp + .get_mut_or_default::(); + // try to determine whether the modal can be shown + let is_modal_controlled = last_modal_id_opt.is_some(); + is_modal_controlled + .then(|| { + // modal control has been obtained + let previous_focused_id_opt: Option = ctx + .memory() + .data_temp + .get_mut_or_default::() + .previous_focused_id_opt + .take(); + let _ = ctx + .memory() + .data_temp + .get_mut_or_default::() + .last_modal_id_opt + .take(); + previous_focused_id_opt + }) + .flatten() +} +/// Show a modal dialog that intercepts interaction with other ui elements whilst visible. +/// +/// - The returned inner response includes the result of the provided contents ui function as well as the response from clicking the interaction interceptor. +/// - Returns `None` if a modal is already showing. +/// +/// ``` +/// # let mut ctx = egui::CtxRef::default(); +/// # ctx.begin_frame(Default::default()); +/// # let ctx = &ctx; +/// let id_0 = egui::Id::new("my_0th_modal"); +/// let id_1 = egui::Id::new("my_1st_modal"); +/// let r_opt = egui::modal::show_custom_modal( +/// ctx, +/// id_0, +/// None, +/// |ui| { +/// ui.label("This is a modal dialog"); +/// }); +/// assert_eq!(r_opt, Some(()), "A modal dialog with an id may show once"); +/// let r_opt = egui::modal::show_custom_modal( +/// ctx, +/// id_0, +/// None, +/// |ui| { +/// ui.label("This is the same (by id) modal dialog"); +/// }); +/// assert_eq!(r_opt, Some(()), "A modal dialog with an id may show again/update"); +/// let r_opt = egui::modal::show_custom_modal( +/// ctx, +/// id_1, +/// None, +/// |ui| { +/// ui.label("This wants to be a modal dialog, yet shall produce nary a ui ere the grotesque and catastrophic violation of some invariant. "); +/// }); +/// assert_eq!(r_opt, None, "A modal dialog may not appear whilst another has control"); +/// egui::modal::relinquish_modal(ctx); +/// let r_opt = egui::modal::show_custom_modal( +/// ctx, +/// id_1, +/// None, +/// |ui| { +/// ui.label("This wants to be a modal dialog, and its dreams are fulfilled."); +/// }); +/// assert_eq!(r_opt, Some(()), "A modal dialog may appear after another has relinquished control"); +/// ``` +pub fn show_custom_modal( + ctx: &CtxRef, + id: Id, + background_color_opt: Option, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + use containers::*; + // Clone some context state + let previous_focused_id_opt: Option = ctx.memory().focus(); + let last_modal_id_opt = ctx + .memory() + .data_temp + .get_or_default::() + .last_modal_id_opt; + + // Enforce modality + let have_modal_control = last_modal_id_opt.is_none() + || last_modal_id_opt == Some(id) + || last_modal_id_opt == Some(ModalMonoState::get_default_modal_id()); + if have_modal_control { + ctx.memory() + .data_temp + .get_mut_or_default::() + .last_modal_id_opt + .replace(id); + ctx.memory() + .data_temp + .get_mut_or_default::() + .previous_focused_id_opt = previous_focused_id_opt; + // show the modal taking up the whole screen + let InnerResponse { inner, .. } = Area::new(id) + .interactable(true) + .fixed_pos(Pos2::ZERO) + // .order(Order::Foreground) + .show(ctx, |ui| { + let background_color = background_color_opt + .unwrap_or_else(ModalMonoState::get_default_modal_interceptor_color); + let interceptor_rect = ui.ctx().input().screen_rect(); + // create an empty interaction interceptor + // for some reason, using Sense::click() instead of Sense::hover() + // seems to intercept not only clicks to the unoccupied areas but also to the user-provided ui + ui.allocate_response(interceptor_rect.size(), Sense::hover()); + let InnerResponse { + inner: user_ui_inner, + .. + } = ui.allocate_ui_at_rect(interceptor_rect, |ui| { + // create a customizable visual indicator signifying to the user that this is a modal mode + ui.painter() + .add(Shape::rect_filled(interceptor_rect, 0.0, background_color)); + add_contents(ui) + }); + user_ui_inner + }); + Some(inner) + } else { + None + } +} diff --git a/egui_demo_lib/src/apps/demo/demo_app_windows.rs b/egui_demo_lib/src/apps/demo/demo_app_windows.rs index 586e1b2c627..092ca9e9b29 100644 --- a/egui_demo_lib/src/apps/demo/demo_app_windows.rs +++ b/egui_demo_lib/src/apps/demo/demo_app_windows.rs @@ -23,6 +23,7 @@ impl Default for Demos { Box::new(super::font_book::FontBook::default()), Box::new(super::MiscDemoWindow::default()), Box::new(super::multi_touch::MultiTouch::default()), + Box::new(super::modals::ModalOptions::default()), Box::new(super::painting::Painting::default()), Box::new(super::plot_demo::PlotDemo::default()), Box::new(super::scrolling::Scrolling::default()), @@ -43,7 +44,7 @@ impl Demos { .name() .to_owned(), ); - + Self { demos, open } } diff --git a/egui_demo_lib/src/apps/demo/mod.rs b/egui_demo_lib/src/apps/demo/mod.rs index f519aeec738..45e2ced4b7f 100644 --- a/egui_demo_lib/src/apps/demo/mod.rs +++ b/egui_demo_lib/src/apps/demo/mod.rs @@ -13,6 +13,7 @@ pub mod drag_and_drop; pub mod font_book; pub mod layout_test; pub mod misc_demo_window; +pub mod modals; pub mod multi_touch; pub mod painting; pub mod password; diff --git a/egui_demo_lib/src/apps/demo/modals.rs b/egui_demo_lib/src/apps/demo/modals.rs new file mode 100644 index 00000000000..af6218864f6 --- /dev/null +++ b/egui_demo_lib/src/apps/demo/modals.rs @@ -0,0 +1,146 @@ +use egui::*; + +#[derive(Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ModalOptions { + id_source: String, + click_away_dismisses: bool, + background_color: Color32, + close_key_opt: Option, + is_modal_showing: bool, + id_error_message_opt: Option, +} + +impl Default for ModalOptions { + fn default() -> Self { + Self { + click_away_dismisses: true, + background_color: Color32::from_rgba_unmultiplied(64, 64, 64, 192), + close_key_opt: Some(Key::Escape), + id_source: String::from("demo_modal_options"), + is_modal_showing: false, + id_error_message_opt: None, + } + } +} + +impl super::Demo for ModalOptions { + fn name(&self) -> &'static str { + "! Modal Options" + } + + fn show(&mut self, ctx: &egui::CtxRef, open: &mut bool) { + use super::View as _; + + // Create a window for controlling modal details + egui::Window::new("demo_modal_options") + .open(open) + .show(ctx, |ui| self.ui(ui)); + } +} + +impl super::View for ModalOptions { + fn ui(&mut self, ui: &mut egui::Ui) { + let Self { + click_away_dismisses, + background_color, + close_key_opt, + id_source, + is_modal_showing, + id_error_message_opt, + } = self; + + ui.group(|ui| { + ui.horizontal(|ui| { + if ui.button("show modal").clicked() { + *is_modal_showing = true; + } + }); + }); + + ui.horizontal(|ui| { + ui.group(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("Id:"); + ui.text_edit_singleline(id_source) + .on_hover_text("Enter a custom id"); + }); + if let Some(id_error_message) = id_error_message_opt.as_ref() { + *is_modal_showing = false; + ui.colored_label(Color32::RED, id_error_message); + if ui.button("Relinquish id-based control").clicked() { + egui::modal::relinquish_modal(ui.ctx()); + *id_error_message_opt = None; + } + } + // ui.checkbox( + // click_away_dismisses, + // "click_away_dismisses" + // ); + }); + }); + }); + let outer_ctx = ui.ctx(); + if *is_modal_showing { + let id = Id::new(id_source); + if let Some(mut response) = egui::modal::show_custom_modal( + outer_ctx, + id, + Some(*background_color), + |_ui| { + // Note that the inner Area needs to be shown with the outer context to appear above the modal interceptor + // Also, the area needs to be in the foreground to appear atop the modal's inner ui + Area::new("An area for some modal content") + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .order(Order::Foreground) + .show(outer_ctx, |ui| { + ui.group(|ui| { + ui.vertical(|ui| { + ui.label(format!("This modal was created with the egui Id `{:?}`", id) ); + // ui.separator(); + ui.label("You cannot interact with the items behind the modal, but you can interact with the ui here."); + if let Some(close_key) = close_key_opt.as_ref() { + // ui.separator(); + ui.label(format!("This modal can be closed using the `{:?}` key", close_key) ); + if ui.ctx().input().key_pressed(*close_key) { + *is_modal_showing = false; + } + } + if ui.button("hide modal").clicked() { + *is_modal_showing = false; + } + if ui.button("hide modal and relinquish id-based control").clicked() { + egui::modal::relinquish_modal(ui.ctx()); + *is_modal_showing = false; + } + egui::widgets::color_picker::color_edit_button_srgba( + ui, + background_color, color_picker::Alpha::BlendOrAdditive + ); + }).response + }).inner + }).inner + }, + ) { + response = response.interact(Sense::click()); + if response.has_focus() && response.clicked_elsewhere() && *click_away_dismisses { + println!("clicked away {:#?}", response); + *is_modal_showing = false; + } + response.request_focus(); + *id_error_message_opt = None; + } else { + *id_error_message_opt = Some( + "relinquish_modal must be called to show a modal with a new id ".to_string(), + ); + } + } + ui.separator(); + + ui.horizontal(|ui| { + egui::reset_button(ui, self); + ui.add(crate::__egui_github_link_file!()); + }); + } +}