elegance/modal.rs
1//! Modal dialog — a centered themed card over a dimmed backdrop.
2//!
3//! Painted in two layers: a full-viewport dimmed backdrop that swallows
4//! clicks (and closes the modal when clicked), and a centered [`Card`]-
5//! like window with an optional heading row and a close "×" button.
6//! Press `Esc` to dismiss.
7
8use egui::{
9 accesskit, Align2, Area, Color32, Context, CornerRadius, Frame, Id, Key, Margin, Order,
10 Response, Sense, Stroke, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
11};
12
13use crate::{theme::Theme, Button, ButtonSize};
14
15/// A centered modal dialog.
16///
17/// The `open` flag drives visibility: when it's `false` on entry to
18/// [`Modal::show`], nothing is rendered; when the user clicks the backdrop,
19/// presses `Esc`, or clicks the "×" button, it's flipped to `false`.
20///
21/// ```no_run
22/// # use elegance::Modal;
23/// # let ctx = egui::Context::default();
24/// # let mut open = true;
25/// Modal::new("stats", &mut open)
26/// .heading("Run Summary")
27/// .show(&ctx, |ui| {
28/// ui.label("…");
29/// });
30/// ```
31#[must_use = "Call `.show(ctx, |ui| { ... })` to render the modal."]
32pub struct Modal<'a> {
33 id_salt: Id,
34 heading: Option<WidgetText>,
35 open: &'a mut bool,
36 max_width: f32,
37 close_on_backdrop: bool,
38 close_on_escape: bool,
39 alert: bool,
40}
41
42impl<'a> std::fmt::Debug for Modal<'a> {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.debug_struct("Modal")
45 .field("id_salt", &self.id_salt)
46 .field("heading", &self.heading.as_ref().map(|h| h.text()))
47 .field("open", &*self.open)
48 .field("max_width", &self.max_width)
49 .field("close_on_backdrop", &self.close_on_backdrop)
50 .field("close_on_escape", &self.close_on_escape)
51 .field("alert", &self.alert)
52 .finish()
53 }
54}
55
56impl<'a> Modal<'a> {
57 /// Create a modal keyed by `id_salt` whose visibility is bound to `open`.
58 pub fn new(id_salt: impl std::hash::Hash, open: &'a mut bool) -> Self {
59 Self {
60 id_salt: Id::new(id_salt),
61 heading: None,
62 open,
63 max_width: 440.0,
64 close_on_backdrop: true,
65 close_on_escape: true,
66 alert: false,
67 }
68 }
69
70 /// Show a strong heading at the top of the modal, alongside the close button.
71 pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
72 self.heading = Some(heading.into());
73 self
74 }
75
76 /// Override the maximum width of the modal card in points. Default: 440.
77 pub fn max_width(mut self, max_width: f32) -> Self {
78 self.max_width = max_width;
79 self
80 }
81
82 /// Whether clicking the dimmed backdrop dismisses the modal. Default: `true`.
83 pub fn close_on_backdrop(mut self, close: bool) -> Self {
84 self.close_on_backdrop = close;
85 self
86 }
87
88 /// Whether pressing `Esc` dismisses the modal. Default: `true`.
89 pub fn close_on_escape(mut self, close: bool) -> Self {
90 self.close_on_escape = close;
91 self
92 }
93
94 /// Mark this modal as an *alert dialog* — a dialog that demands the
95 /// user's attention to proceed, such as a destructive confirmation or
96 /// an unsaved-changes prompt. Screen readers announce alert dialogs
97 /// more assertively than ordinary dialogs. Default: `false`.
98 ///
99 /// Under the hood this exposes `accesskit::Role::AlertDialog` on the
100 /// modal's root node instead of the default `Role::Dialog`.
101 pub fn alert(mut self, alert: bool) -> Self {
102 self.alert = alert;
103 self
104 }
105
106 /// Render the modal. Returns `None` if the modal was suppressed because
107 /// the bound `open` flag was `false`; otherwise returns `Some(R)` with
108 /// the content closure's return value.
109 pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
110 // --- Focus lifecycle ------------------------------------------------
111 // Track the open/closed transition so we can (a) record which widget
112 // had keyboard focus before the modal opened and (b) restore that
113 // focus when the modal closes. Without this the user's focus is
114 // visually eclipsed by the modal but structurally remains behind it —
115 // Tab would navigate widgets on the underlying page.
116 let focus_storage = Id::new(("elegance_modal_focus", self.id_salt));
117 let mut focus_state: ModalFocusState =
118 ctx.data(|d| d.get_temp(focus_storage).unwrap_or_default());
119 let is_open = *self.open;
120
121 if focus_state.was_open && !is_open {
122 // Just closed this frame — return focus to whatever had it before.
123 if let Some(prev) = focus_state.prev_focus {
124 ctx.memory_mut(|m| m.request_focus(prev));
125 }
126 ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
127 return None;
128 }
129
130 if !is_open {
131 return None;
132 }
133
134 let just_opened = !focus_state.was_open;
135 if just_opened {
136 focus_state.prev_focus = ctx.memory(|m| m.focused());
137 focus_state.was_open = true;
138 ctx.data_mut(|d| d.insert_temp(focus_storage, focus_state));
139 }
140
141 let theme = Theme::current(ctx);
142 let p = &theme.palette;
143 let mut should_close = false;
144 let mut close_btn_id: Option<Id> = None;
145
146 // --- Backdrop ----------------------------------------------------
147 let screen = ctx.content_rect();
148 let backdrop_id = Id::new("elegance_modal_backdrop").with(self.id_salt);
149 let backdrop = Area::new(backdrop_id)
150 .fixed_pos(screen.min)
151 .order(Order::Middle)
152 .show(ctx, |ui| {
153 ui.painter().rect_filled(
154 screen,
155 CornerRadius::ZERO,
156 Color32::from_rgba_premultiplied(0, 0, 0, 150),
157 );
158 ui.allocate_rect(screen, Sense::click())
159 });
160 if self.close_on_backdrop && backdrop.inner.clicked() {
161 should_close = true;
162 }
163
164 // --- Content -----------------------------------------------------
165 let window_id = Id::new("elegance_modal_window").with(self.id_salt);
166 let alert = self.alert;
167 let heading_text: Option<String> = self.heading.as_ref().map(|h| h.text().to_string());
168 let result = Area::new(window_id)
169 .order(Order::Foreground)
170 .anchor(Align2::CENTER_CENTER, Vec2::ZERO)
171 .show(ctx, |ui| {
172 // Upgrade this Ui's accesskit role from `GenericContainer`
173 // (set automatically by `Ui::new`) to a dialog role, so
174 // screen readers announce the modal correctly and
175 // platforms that support dialog focus tracking (AT-SPI)
176 // treat it as a window-like surface.
177 let role = if alert {
178 accesskit::Role::AlertDialog
179 } else {
180 accesskit::Role::Dialog
181 };
182 let heading_for_label = heading_text.clone();
183 ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
184 node.set_role(role);
185 if let Some(label) = heading_for_label {
186 node.set_label(label);
187 }
188 });
189
190 ui.set_max_width(self.max_width);
191 Frame::new()
192 .fill(p.card)
193 .stroke(Stroke::new(1.0, p.border))
194 .corner_radius(CornerRadius::same(theme.card_radius as u8))
195 .inner_margin(Margin::same(theme.card_padding as i8))
196 .show(ui, |ui| {
197 let has_heading = self.heading.is_some();
198 if has_heading {
199 ui.horizontal(|ui| {
200 if let Some(h) = &self.heading {
201 ui.add(egui::Label::new(theme.heading_text(h.text())));
202 }
203 ui.with_layout(
204 egui::Layout::right_to_left(egui::Align::Center),
205 |ui| {
206 let resp = close_button(ui);
207 if resp.clicked() {
208 should_close = true;
209 }
210 close_btn_id = Some(resp.id);
211 },
212 );
213 });
214 ui.add_space(6.0);
215 ui.separator();
216 ui.add_space(10.0);
217 }
218 add_contents(ui)
219 })
220 });
221
222 if self.close_on_escape && ctx.input(|i| i.key_pressed(Key::Escape)) {
223 should_close = true;
224 }
225
226 // On the first frame a modal is open, move keyboard focus into it so
227 // Tab navigates within the dialog rather than the background. We
228 // target the close button when a heading is present (it has a
229 // stable id and is always interactive); without a heading there's
230 // no intrinsic focus target, so focus is left to the caller.
231 if just_opened {
232 if let Some(id) = close_btn_id {
233 ctx.memory_mut(|m| m.request_focus(id));
234 }
235 }
236
237 if should_close {
238 *self.open = false;
239 }
240
241 Some(result.inner.inner)
242 }
243}
244
245/// Persistent focus-lifecycle state for a single `Modal`, keyed by the
246/// modal's `id_salt`. Stored via `ctx.data_mut`.
247#[derive(Clone, Copy, Default, Debug)]
248struct ModalFocusState {
249 /// Whether the modal was rendered open last frame. Used to detect
250 /// open/close transitions.
251 was_open: bool,
252 /// Which widget (if any) had keyboard focus at the moment the modal
253 /// opened. Restored on close.
254 prev_focus: Option<Id>,
255}
256
257/// Render the modal's close button. Returns its `Response` so the caller
258/// can route focus to it and check `clicked()`. The accesskit label is
259/// set to `"Close"` explicitly — without this, screen readers announce
260/// the "×" glyph literally as "multiplication sign."
261///
262/// The button is scoped under a stable id (`"elegance_modal_close"`) so
263/// focus requests targeting it survive layout changes.
264fn close_button(ui: &mut Ui) -> Response {
265 let inner = ui
266 .push_id("elegance_modal_close", |ui| {
267 ui.add(Button::new("×").outline().size(ButtonSize::Small))
268 })
269 .inner;
270 let enabled = inner.enabled();
271 inner.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, "Close"));
272 inner
273}