Add `Modal` and `Memory::set_modal_layer` (#5358)

* Closes #686 
* Closes #839 
* #5370 should be merged before this
* [x] I have followed the instructions in the PR template

This adds modals to egui. 
This PR
- adds a new `Modal` struct
- adds `Memory::set_modal_layer` to limit focus to a layer and above
(used by the modal struct, but could also be used by custom modal
implementations)
- adds `Memory::allows_interaction` to check if a layer is behind a
modal layer, deprecating `Layer::allows_interaction`



Current problems:
- ~When a button is focused before the modal opens, it stays focused and
you also can't hit tab to focus the next widget. Seems like focus is
"stuck" on that widget until you hit escape. This might be related to
https://github.com/emilk/egui/issues/5359~ fixed!

Possible future improvements: 
- The titlebar from `window` should be made into a separate widget and
added to the modal
- The state whether the modal is open should be stored in egui
(optionally), similar to popup and menu. Ideally before this we would
refactor popup state to unify popup and menu

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
lucasmerlin 2024-11-28 16:52:05 +01:00 committed by GitHub
parent 84cc1572b1
commit 10791cc43d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 574 additions and 10 deletions

View File

@ -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]]

View File

@ -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,

View File

@ -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<Frame>,
}
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<T>(self, ctx: &Context, content: impl FnOnce(&mut Ui) -> T) -> ModalResponse<T> {
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<T> {
/// 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<T> ModalResponse<T> {
/// 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)
}
}

View File

@ -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);
}
});

View File

@ -95,6 +95,7 @@ impl LayerId {
}
#[inline(always)]
#[deprecated = "Use `Memory::allows_interaction` instead"]
pub fn allow_interaction(&self) -> bool {
self.order.allow_interaction()
}

View File

@ -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<LayerId>,
/// The top-most modal layer from the current frame.
top_modal_layer_current_frame: Option<LayerId>,
/// A cache of widget IDs that are interested in focus with their corresponding rectangles.
focus_widgets_cache: IdMap<Rect>,
}
@ -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<LayerId> {
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<LayerId> {
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<LayerId> {
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<LayerId, usize>;
/// 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<LayerId>,
/// Actual order of the layers, pre-calculated each frame.
order_map: OrderMap,
visible_last_frame: ahash::HashSet<LayerId>,
visible_current_frame: ahash::HashSet<LayerId>,
@ -1079,12 +1158,28 @@ impl Areas {
}
/// For each layer, which [`Self::order`] is it in?
pub(crate) fn order_map(&self) -> HashMap<LayerId, usize> {
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();
}
}

View File

@ -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)
});

View File

@ -33,6 +33,7 @@ impl Default for Demos {
Box::<super::highlighting::Highlighting>::default(),
Box::<super::interactive_container::InteractiveContainerDemo>::default(),
Box::<super::MiscDemoWindow>::default(),
Box::<super::modals::Modals>::default(),
Box::<super::multi_touch::MultiTouch>::default(),
Box::<super::painting::Painting>::default(),
Box::<super::pan_zoom::PanZoom>::default(),

View File

@ -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;

View File

@ -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<f32>,
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");
}
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:026723cb5d89b32386a849328c34420ee9e3ae1f97cbf6fa3c4543141123549e
size 32890

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8348ff582e11fdc9baf008b5434f81f8d77b834479cb3765c87d1f4fd695e30f
size 48212

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23482b77cbd817c66421a630e409ac3d8c5d24de00aa91e476e8d42b607c24b1
size 48104

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d94aa33d72c32f6f1aafab92c9753dc07bc5224c701003ac7fe8a01ae8c701a
size 44011

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e1a5d265470c36e64340ccceea4ade464b3c4a1177d60630b02ae8287934748f
size 44026