diff --git a/Cargo.lock b/Cargo.lock index ac6b32fc795..0e44fd28ba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,7 +2239,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" version = "0.1.0" -source = "git+https://github.com/rerun-io/kittest?branch=main#63c5b7d58178900e523428ca5edecbba007a2702" +source = "git+https://github.com/rerun-io/kittest?branch=main#06e01f17fed36a997e1541f37b2d47e3771d7533" dependencies = [ "accesskit", "accesskit_consumer", @@ -2274,7 +2274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index 3dd75a4458e..e68e0def1b0 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod area; pub mod collapsing_header; mod combo_box; pub mod frame; +pub mod modal; pub mod panel; pub mod popup; pub(crate) mod resize; @@ -18,6 +19,7 @@ pub use { collapsing_header::{CollapsingHeader, CollapsingResponse}, combo_box::*, frame::Frame, + modal::{Modal, ModalResponse}, panel::{CentralPanel, SidePanel, TopBottomPanel}, popup::*, resize::Resize, diff --git a/crates/egui/src/containers/modal.rs b/crates/egui/src/containers/modal.rs new file mode 100644 index 00000000000..25f3fbce7ca --- /dev/null +++ b/crates/egui/src/containers/modal.rs @@ -0,0 +1,160 @@ +use crate::{ + Area, Color32, Context, Frame, Id, InnerResponse, Order, Response, Sense, Ui, UiBuilder, +}; +use emath::{Align2, Vec2}; + +/// A modal dialog. +/// Similar to a [`crate::Window`] but centered and with a backdrop that +/// blocks input to the rest of the UI. +/// +/// You can show multiple modals on top of each other. The topmost modal will always be +/// the most recently shown one. +pub struct Modal { + pub area: Area, + pub backdrop_color: Color32, + pub frame: Option, +} + +impl Modal { + /// Create a new Modal. The id is passed to the area. + pub fn new(id: Id) -> Self { + Self { + area: Self::default_area(id), + backdrop_color: Color32::from_black_alpha(100), + frame: None, + } + } + + /// Returns an area customized for a modal. + /// Makes these changes to the default area: + /// - sense: hover + /// - anchor: center + /// - order: foreground + pub fn default_area(id: Id) -> Area { + Area::new(id) + .sense(Sense::hover()) + .anchor(Align2::CENTER_CENTER, Vec2::ZERO) + .order(Order::Foreground) + .interactable(true) + } + + /// Set the frame of the modal. + /// + /// Default is [`Frame::popup`]. + #[inline] + pub fn frame(mut self, frame: Frame) -> Self { + self.frame = Some(frame); + self + } + + /// Set the backdrop color of the modal. + /// + /// Default is `Color32::from_black_alpha(100)`. + #[inline] + pub fn backdrop_color(mut self, color: Color32) -> Self { + self.backdrop_color = color; + self + } + + /// Set the area of the modal. + /// + /// Default is [`Modal::default_area`]. + #[inline] + pub fn area(mut self, area: Area) -> Self { + self.area = area; + self + } + + /// Show the modal. + pub fn show(self, ctx: &Context, content: impl FnOnce(&mut Ui) -> T) -> ModalResponse { + let Self { + area, + backdrop_color, + frame, + } = self; + + let (is_top_modal, any_popup_open) = ctx.memory_mut(|mem| { + mem.set_modal_layer(area.layer()); + ( + mem.top_modal_layer() == Some(area.layer()), + mem.any_popup_open(), + ) + }); + let InnerResponse { + inner: (inner, backdrop_response), + response, + } = area.show(ctx, |ui| { + let bg_rect = ui.ctx().screen_rect(); + let bg_sense = Sense { + click: true, + drag: true, + focusable: false, + }; + let mut backdrop = ui.new_child(UiBuilder::new().sense(bg_sense).max_rect(bg_rect)); + backdrop.set_min_size(bg_rect.size()); + ui.painter().rect_filled(bg_rect, 0.0, backdrop_color); + let backdrop_response = backdrop.response(); + + let frame = frame.unwrap_or_else(|| Frame::popup(ui.style())); + + // We need the extra scope with the sense since frame can't have a sense and since we + // need to prevent the clicks from passing through to the backdrop. + let inner = ui + .scope_builder( + UiBuilder::new().sense(Sense { + click: true, + drag: true, + focusable: false, + }), + |ui| frame.show(ui, content).inner, + ) + .inner; + + (inner, backdrop_response) + }); + + ModalResponse { + response, + backdrop_response, + inner, + is_top_modal, + any_popup_open, + } + } +} + +/// The response of a modal dialog. +pub struct ModalResponse { + /// The response of the modal contents + pub response: Response, + + /// The response of the modal backdrop. + /// + /// A click on this means the user clicked outside the modal, + /// in which case you might want to close the modal. + pub backdrop_response: Response, + + /// The inner response from the content closure + pub inner: T, + + /// Is this the topmost modal? + pub is_top_modal: bool, + + /// Is there any popup open? + /// We need to check this before the modal contents are shown, so we can know if any popup + /// was open when checking if the escape key was clicked. + pub any_popup_open: bool, +} + +impl ModalResponse { + /// Should the modal be closed? + /// Returns true if: + /// - the backdrop was clicked + /// - this is the topmost modal, no popup is open and the escape key was pressed + pub fn should_close(&self) -> bool { + let ctx = &self.response.ctx; + let escape_clicked = ctx.input(|i| i.key_pressed(crate::Key::Escape)); + self.backdrop_response.clicked() + || (self.is_top_modal && !self.any_popup_open && escape_clicked) + } +} diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 554ea872261..cffc37c3293 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1160,7 +1160,8 @@ impl Context { /// same widget, then `allow_focus` should only be true once (like in [`Ui::new`] (true) and [`Ui::remember_min_rect`] (false)). #[allow(clippy::too_many_arguments)] pub(crate) fn create_widget(&self, w: WidgetRect, allow_focus: bool) -> Response { - let interested_in_focus = w.enabled && w.sense.focusable && w.layer_id.allow_interaction(); + let interested_in_focus = + w.enabled && w.sense.focusable && self.memory(|mem| mem.allows_interaction(w.layer_id)); // Remember this widget self.write(|ctx| { @@ -1172,7 +1173,7 @@ impl Context { viewport.this_pass.widgets.insert(w.layer_id, w); if allow_focus && interested_in_focus { - ctx.memory.interested_in_focus(w.id); + ctx.memory.interested_in_focus(w.id, w.layer_id); } }); diff --git a/crates/egui/src/layers.rs b/crates/egui/src/layers.rs index 9105ece2591..b44daa41858 100644 --- a/crates/egui/src/layers.rs +++ b/crates/egui/src/layers.rs @@ -95,6 +95,7 @@ impl LayerId { } #[inline(always)] + #[deprecated = "Use `Memory::allows_interaction` instead"] pub fn allow_interaction(&self) -> bool { self.order.allow_interaction() } diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 75183bdd15b..e5ca8d42789 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -513,6 +513,12 @@ pub(crate) struct Focus { /// Set when looking for widget with navigational keys like arrows, tab, shift+tab. focus_direction: FocusDirection, + /// The top-most modal layer from the previous frame. + top_modal_layer: Option, + + /// The top-most modal layer from the current frame. + top_modal_layer_current_frame: Option, + /// A cache of widget IDs that are interested in focus with their corresponding rectangles. focus_widgets_cache: IdMap, } @@ -623,6 +629,8 @@ impl Focus { self.focused_widget = None; } } + + self.top_modal_layer = self.top_modal_layer_current_frame.take(); } pub(crate) fn had_focus_last_frame(&self, id: Id) -> bool { @@ -676,6 +684,14 @@ impl Focus { self.last_interested = Some(id); } + fn set_modal_layer(&mut self, layer_id: LayerId) { + self.top_modal_layer_current_frame = Some(layer_id); + } + + pub(crate) fn top_modal_layer(&self) -> Option { + self.top_modal_layer + } + fn reset_focus(&mut self) { self.focus_direction = FocusDirection::None; } @@ -802,7 +818,15 @@ impl Memory { /// Top-most layer at the given position. pub fn layer_id_at(&self, pos: Pos2) -> Option { - self.areas().layer_id_at(pos, &self.layer_transforms) + self.areas() + .layer_id_at(pos, &self.layer_transforms) + .and_then(|layer_id| { + if self.is_above_modal_layer(layer_id) { + Some(layer_id) + } else { + self.top_modal_layer() + } + }) } /// An iterator over all layers. Back-to-front, top is last. @@ -877,6 +901,30 @@ impl Memory { } } + /// Returns true if + /// - this layer is the top-most modal layer or above it + /// - there is no modal layer + pub fn is_above_modal_layer(&self, layer_id: LayerId) -> bool { + if let Some(modal_layer) = self.focus().and_then(|f| f.top_modal_layer) { + matches!( + self.areas().compare_order(layer_id, modal_layer), + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater + ) + } else { + true + } + } + + /// Does this layer allow interaction? + /// Returns true if + /// - the layer is not behind a modal layer + /// - the [`Order`] allows interaction + pub fn allows_interaction(&self, layer_id: LayerId) -> bool { + let is_above_modal_layer = self.is_above_modal_layer(layer_id); + let ordering_allows_interaction = layer_id.order.allow_interaction(); + is_above_modal_layer && ordering_allows_interaction + } + /// Register this widget as being interested in getting keyboard focus. /// This will allow the user to select it with tab and shift-tab. /// This is normally done automatically when handling interactions, @@ -884,11 +932,36 @@ impl Memory { /// e.g. before deciding which type of underlying widget to use, /// as in the [`crate::DragValue`] widget, so a widget can be focused /// and rendered correctly in a single frame. + /// + /// Pass in the `layer_id` of the layer that the widget is in. #[inline(always)] - pub fn interested_in_focus(&mut self, id: Id) { + pub fn interested_in_focus(&mut self, id: Id, layer_id: LayerId) { + if !self.allows_interaction(layer_id) { + return; + } self.focus_mut().interested_in_focus(id); } + /// Limit focus to widgets on the given layer and above. + /// If this is called multiple times per frame, the top layer wins. + pub fn set_modal_layer(&mut self, layer_id: LayerId) { + if let Some(current) = self.focus().and_then(|f| f.top_modal_layer_current_frame) { + if matches!( + self.areas().compare_order(layer_id, current), + std::cmp::Ordering::Less + ) { + return; + } + } + + self.focus_mut().set_modal_layer(layer_id); + } + + /// Get the top modal layer (from the previous frame). + pub fn top_modal_layer(&self) -> Option { + self.focus()?.top_modal_layer() + } + /// Stop editing the active [`TextEdit`](crate::TextEdit) (if any). #[inline(always)] pub fn stop_text_input(&mut self) { @@ -1037,6 +1110,9 @@ impl Memory { // ---------------------------------------------------------------------------- +/// Map containing the index of each layer in the order list, for quick lookups. +type OrderMap = HashMap; + /// Keeps track of [`Area`](crate::containers::area::Area)s, which are free-floating [`Ui`](crate::Ui)s. /// These [`Area`](crate::containers::area::Area)s can be in any [`Order`]. #[derive(Clone, Debug, Default)] @@ -1048,6 +1124,9 @@ pub struct Areas { /// Back-to-front, top is last. order: Vec, + /// Actual order of the layers, pre-calculated each frame. + order_map: OrderMap, + visible_last_frame: ahash::HashSet, visible_current_frame: ahash::HashSet, @@ -1079,12 +1158,28 @@ impl Areas { } /// For each layer, which [`Self::order`] is it in? - pub(crate) fn order_map(&self) -> HashMap { - self.order + pub(crate) fn order_map(&self) -> &OrderMap { + &self.order_map + } + + /// Compare the order of two layers, based on the order list from last frame. + /// May return [`std::cmp::Ordering::Equal`] if the layers are not in the order list. + pub(crate) fn compare_order(&self, a: LayerId, b: LayerId) -> std::cmp::Ordering { + if let (Some(a), Some(b)) = (self.order_map.get(&a), self.order_map.get(&b)) { + a.cmp(b) + } else { + a.order.cmp(&b.order) + } + } + + /// Calculates the order map. + fn calculate_order_map(&mut self) { + self.order_map = self + .order .iter() .enumerate() .map(|(i, id)| (*id, i)) - .collect() + .collect(); } pub(crate) fn set_state(&mut self, layer_id: LayerId, state: area::AreaState) { @@ -1209,6 +1304,7 @@ impl Areas { }; order.splice(parent_pos..=parent_pos, moved_layers); } + self.calculate_order_map(); } } diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index feec4d07ff5..a5b8c25b2f8 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -452,7 +452,7 @@ impl<'a> Widget for DragValue<'a> { // in button mode for just one frame. This is important for // screen readers. let is_kb_editing = ui.memory_mut(|mem| { - mem.interested_in_focus(id); + mem.interested_in_focus(id, ui.layer_id()); mem.has_focus(id) }); diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 7b3f263831b..8729d148f11 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -33,6 +33,7 @@ impl Default for Demos { Box::::default(), Box::::default(), Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index 828bdd896a3..8c9034868e4 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -17,6 +17,7 @@ pub mod frame_demo; pub mod highlighting; pub mod interactive_container; pub mod misc_demo_window; +pub mod modals; pub mod multi_touch; pub mod paint_bezier; pub mod painting; diff --git a/crates/egui_demo_lib/src/demo/modals.rs b/crates/egui_demo_lib/src/demo/modals.rs new file mode 100644 index 00000000000..989101b4d75 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/modals.rs @@ -0,0 +1,287 @@ +use egui::{ComboBox, Context, Id, Modal, ProgressBar, Ui, Widget, Window}; + +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct Modals { + user_modal_open: bool, + save_modal_open: bool, + save_progress: Option, + + role: &'static str, + name: String, +} + +impl Default for Modals { + fn default() -> Self { + Self { + user_modal_open: false, + save_modal_open: false, + save_progress: None, + role: Self::ROLES[0], + name: "John Doe".to_owned(), + } + } +} + +impl Modals { + const ROLES: [&'static str; 2] = ["user", "admin"]; +} + +impl crate::Demo for Modals { + fn name(&self) -> &'static str { + "🗖 Modals" + } + + fn show(&mut self, ctx: &Context, open: &mut bool) { + use crate::View as _; + Window::new(self.name()) + .open(open) + .vscroll(false) + .resizable(false) + .show(ctx, |ui| self.ui(ui)); + } +} + +impl crate::View for Modals { + fn ui(&mut self, ui: &mut Ui) { + let Self { + user_modal_open, + save_modal_open, + save_progress, + role, + name, + } = self; + + ui.horizontal(|ui| { + if ui.button("Open User Modal").clicked() { + *user_modal_open = true; + } + + if ui.button("Open Save Modal").clicked() { + *save_modal_open = true; + } + }); + + ui.label("Click one of the buttons to open a modal."); + ui.label("Modals have a backdrop and prevent interaction with the rest of the UI."); + ui.label( + "You can show modals on top of each other and close the topmost modal with \ + escape or by clicking outside the modal.", + ); + + if *user_modal_open { + let modal = Modal::new(Id::new("Modal A")).show(ui.ctx(), |ui| { + ui.set_width(250.0); + + ui.heading("Edit User"); + + ui.label("Name:"); + ui.text_edit_singleline(name); + + ComboBox::new("role", "Role") + .selected_text(*role) + .show_ui(ui, |ui| { + for r in Self::ROLES { + ui.selectable_value(role, r, r); + } + }); + + ui.separator(); + + egui::Sides::new().show( + ui, + |_ui| {}, + |ui| { + if ui.button("Save").clicked() { + *save_modal_open = true; + } + if ui.button("Cancel").clicked() { + *user_modal_open = false; + } + }, + ); + }); + + if modal.should_close() { + *user_modal_open = false; + } + } + + if *save_modal_open { + let modal = Modal::new(Id::new("Modal B")).show(ui.ctx(), |ui| { + ui.set_width(200.0); + ui.heading("Save? Are you sure?"); + + ui.add_space(32.0); + + egui::Sides::new().show( + ui, + |_ui| {}, + |ui| { + if ui.button("Yes Please").clicked() { + *save_progress = Some(0.0); + } + + if ui.button("No Thanks").clicked() { + *save_modal_open = false; + } + }, + ); + }); + + if modal.should_close() { + *save_modal_open = false; + } + } + + if let Some(progress) = *save_progress { + Modal::new(Id::new("Modal C")).show(ui.ctx(), |ui| { + ui.set_width(70.0); + ui.heading("Saving…"); + + ProgressBar::new(progress).ui(ui); + + if progress >= 1.0 { + *save_progress = None; + *save_modal_open = false; + *user_modal_open = false; + } else { + *save_progress = Some(progress + 0.003); + ui.ctx().request_repaint(); + } + }); + } + + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file!()); + }); + } +} + +#[cfg(test)] +mod tests { + use crate::demo::modals::Modals; + use crate::Demo; + use egui::accesskit::Role; + use egui::Key; + use egui_kittest::kittest::Queryable; + use egui_kittest::Harness; + + #[test] + fn clicking_escape_when_popup_open_should_not_close_modal() { + let initial_state = Modals { + user_modal_open: true, + ..Modals::default() + }; + + let mut harness = Harness::new_state( + |ctx, modals| { + modals.show(ctx, &mut true); + }, + initial_state, + ); + + harness.get_by_role(Role::ComboBox).click(); + + harness.run(); + assert!(harness.ctx.memory(|mem| mem.any_popup_open())); + assert!(harness.state().user_modal_open); + + harness.press_key(Key::Escape); + harness.run(); + assert!(!harness.ctx.memory(|mem| mem.any_popup_open())); + assert!(harness.state().user_modal_open); + } + + #[test] + fn escape_should_close_top_modal() { + let initial_state = Modals { + user_modal_open: true, + save_modal_open: true, + ..Modals::default() + }; + + let mut harness = Harness::new_state( + |ctx, modals| { + modals.show(ctx, &mut true); + }, + initial_state, + ); + + assert!(harness.state().user_modal_open); + assert!(harness.state().save_modal_open); + + harness.press_key(Key::Escape); + harness.run(); + + assert!(harness.state().user_modal_open); + assert!(!harness.state().save_modal_open); + } + + #[test] + fn should_match_snapshot() { + let initial_state = Modals { + user_modal_open: true, + ..Modals::default() + }; + + let mut harness = Harness::new_state( + |ctx, modals| { + modals.show(ctx, &mut true); + }, + initial_state, + ); + + let mut results = Vec::new(); + + harness.run(); + results.push(harness.try_wgpu_snapshot("modals_1")); + + harness.get_by_label("Save").click(); + // TODO(lucasmerlin): Remove these extra runs once run checks for repaint requests + harness.run(); + harness.run(); + harness.run(); + results.push(harness.try_wgpu_snapshot("modals_2")); + + harness.get_by_label("Yes Please").click(); + // TODO(lucasmerlin): Remove these extra runs once run checks for repaint requests + harness.run(); + harness.run(); + harness.run(); + results.push(harness.try_wgpu_snapshot("modals_3")); + + for result in results { + result.unwrap(); + } + } + + // This tests whether the backdrop actually prevents interaction with lower layers. + #[test] + fn backdrop_should_prevent_focusing_lower_area() { + let initial_state = Modals { + save_modal_open: true, + save_progress: Some(0.0), + ..Modals::default() + }; + + let mut harness = Harness::new_state( + |ctx, modals| { + modals.show(ctx, &mut true); + }, + initial_state, + ); + + // TODO(lucasmerlin): Remove these extra runs once run checks for repaint requests + harness.run(); + harness.run(); + harness.run(); + + harness.get_by_label("Yes Please").simulate_click(); + + harness.run(); + + // This snapshots should show the progress bar modal on top of the save modal. + harness.wgpu_snapshot("modals_backdrop_should_prevent_focusing_lower_area"); + } +} diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Modals.png b/crates/egui_demo_lib/tests/snapshots/demos/Modals.png new file mode 100644 index 00000000000..274b4b57686 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Modals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:026723cb5d89b32386a849328c34420ee9e3ae1f97cbf6fa3c4543141123549e +size 32890 diff --git a/crates/egui_demo_lib/tests/snapshots/modals_1.png b/crates/egui_demo_lib/tests/snapshots/modals_1.png new file mode 100644 index 00000000000..461bef728bc --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/modals_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8348ff582e11fdc9baf008b5434f81f8d77b834479cb3765c87d1f4fd695e30f +size 48212 diff --git a/crates/egui_demo_lib/tests/snapshots/modals_2.png b/crates/egui_demo_lib/tests/snapshots/modals_2.png new file mode 100644 index 00000000000..0f1273f41df --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/modals_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23482b77cbd817c66421a630e409ac3d8c5d24de00aa91e476e8d42b607c24b1 +size 48104 diff --git a/crates/egui_demo_lib/tests/snapshots/modals_3.png b/crates/egui_demo_lib/tests/snapshots/modals_3.png new file mode 100644 index 00000000000..ba8cca6228b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/modals_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d94aa33d72c32f6f1aafab92c9753dc07bc5224c701003ac7fe8a01ae8c701a +size 44011 diff --git a/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png b/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png new file mode 100644 index 00000000000..14d6fb9cbfc --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/modals_backdrop_should_prevent_focusing_lower_area.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1a5d265470c36e64340ccceea4ade464b3c4a1177d60630b02ae8287934748f +size 44026