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