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, Align, Align2, Area, Color32, Context, CornerRadius, FontId, Frame, Id, Key, Layout,
10 Margin, Order, Response, Sense, Stroke, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
11};
12
13use crate::{theme::Theme, Accent, Button, ButtonSize};
14
15/// Boxed `FnOnce(&mut Ui)` callback used by the footer slots.
16type UiFn<'a> = Box<dyn FnOnce(&mut Ui) + 'a>;
17
18/// A centered modal dialog.
19///
20/// The `open` flag drives visibility: when it's `false` on entry to
21/// [`Modal::show`], nothing is rendered; when the user clicks the backdrop,
22/// presses `Esc`, or clicks the "×" button, it's flipped to `false`.
23///
24/// ```no_run
25/// # use elegance::Modal;
26/// # let ctx = egui::Context::default();
27/// # let mut open = true;
28/// Modal::new("stats", &mut open)
29/// .heading("Run Summary")
30/// .show(&ctx, |ui| {
31/// ui.label("…");
32/// });
33/// ```
34#[must_use = "Call `.show(ctx, |ui| { ... })` to render the modal."]
35pub struct Modal<'a> {
36 id_salt: Id,
37 heading: Option<WidgetText>,
38 subtitle: Option<WidgetText>,
39 header_icon: Option<WidgetText>,
40 header_accent: Option<Accent>,
41 open: &'a mut bool,
42 max_width: f32,
43 close_on_backdrop: bool,
44 close_on_escape: bool,
45 alert: bool,
46 footer: Option<UiFn<'a>>,
47 footer_left: Option<UiFn<'a>>,
48}
49
50impl<'a> std::fmt::Debug for Modal<'a> {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("Modal")
53 .field("id_salt", &self.id_salt)
54 .field("heading", &self.heading.as_ref().map(|h| h.text()))
55 .field("subtitle", &self.subtitle.as_ref().map(|h| h.text()))
56 .field("header_icon", &self.header_icon.as_ref().map(|h| h.text()))
57 .field("header_accent", &self.header_accent)
58 .field("open", &*self.open)
59 .field("max_width", &self.max_width)
60 .field("close_on_backdrop", &self.close_on_backdrop)
61 .field("close_on_escape", &self.close_on_escape)
62 .field("alert", &self.alert)
63 .field("footer", &self.footer.as_ref().map(|_| "<closure>"))
64 .field(
65 "footer_left",
66 &self.footer_left.as_ref().map(|_| "<closure>"),
67 )
68 .finish()
69 }
70}
71
72impl<'a> Modal<'a> {
73 /// Create a modal keyed by `id_salt` whose visibility is bound to `open`.
74 pub fn new(id_salt: impl std::hash::Hash, open: &'a mut bool) -> Self {
75 Self {
76 id_salt: Id::new(id_salt),
77 heading: None,
78 subtitle: None,
79 header_icon: None,
80 header_accent: None,
81 open,
82 max_width: 440.0,
83 close_on_backdrop: true,
84 close_on_escape: true,
85 alert: false,
86 footer: None,
87 footer_left: None,
88 }
89 }
90
91 /// Show a strong heading at the top of the modal, alongside the close button.
92 pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
93 self.heading = Some(heading.into());
94 self
95 }
96
97 /// Show a muted subtitle line under the heading.
98 pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
99 self.subtitle = Some(subtitle.into());
100 self
101 }
102
103 /// Paint a glyph in a tinted circular halo to the left of the heading.
104 /// Use any short text — `"⚠"`, `"✓"`, `"!"`, an emoji, or a symbol from
105 /// the bundled `Elegance Symbols` font. The halo's tint comes from
106 /// [`Modal::header_accent`] and defaults to [`Accent::Sky`].
107 pub fn header_icon(mut self, icon: impl Into<WidgetText>) -> Self {
108 self.header_icon = Some(icon.into());
109 self
110 }
111
112 /// Override the accent used for the header icon halo. No-op without
113 /// [`Modal::header_icon`].
114 pub fn header_accent(mut self, accent: Accent) -> Self {
115 self.header_accent = Some(accent);
116 self
117 }
118
119 /// Override the maximum width of the modal card in points. Default: 440.
120 pub fn max_width(mut self, max_width: f32) -> Self {
121 self.max_width = max_width;
122 self
123 }
124
125 /// Whether clicking the dimmed backdrop dismisses the modal. Default: `true`.
126 pub fn close_on_backdrop(mut self, close: bool) -> Self {
127 self.close_on_backdrop = close;
128 self
129 }
130
131 /// Whether pressing `Esc` dismisses the modal. Default: `true`.
132 pub fn close_on_escape(mut self, close: bool) -> Self {
133 self.close_on_escape = close;
134 self
135 }
136
137 /// Mark this modal as an *alert dialog* — a dialog that demands the
138 /// user's attention to proceed, such as a destructive confirmation or
139 /// an unsaved-changes prompt. Screen readers announce alert dialogs
140 /// more assertively than ordinary dialogs. Default: `false`.
141 ///
142 /// Under the hood this exposes `accesskit::Role::AlertDialog` on the
143 /// modal's root node instead of the default `Role::Dialog`.
144 pub fn alert(mut self, alert: bool) -> Self {
145 self.alert = alert;
146 self
147 }
148
149 /// Add a footer row at the bottom of the modal. The closure runs in a
150 /// right-to-left layout, so widgets added in source order land
151 /// rightmost-first — matching the typical "Cancel | Confirm" reading.
152 /// The footer renders below a horizontal divider and over a slightly
153 /// recessed fill, separating it visually from the body.
154 pub fn footer<F: FnOnce(&mut Ui) + 'a>(mut self, add_footer: F) -> Self {
155 self.footer = Some(Box::new(add_footer));
156 self
157 }
158
159 /// Add a left-aligned slot to the footer (only rendered when
160 /// [`Modal::footer`] is also set). Useful for an "export before delete"
161 /// checkbox or a keyboard-shortcut hint that should sit opposite the
162 /// action buttons.
163 pub fn footer_left<F: FnOnce(&mut Ui) + 'a>(mut self, add_left: F) -> Self {
164 self.footer_left = Some(Box::new(add_left));
165 self
166 }
167
168 /// Render the modal. Returns `None` if the modal was suppressed because
169 /// the bound `open` flag was `false`; otherwise returns `Some(R)` with
170 /// the content closure's return value.
171 pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
172 // --- Focus lifecycle ------------------------------------------------
173 // Track the open/closed transition so we can (a) record which widget
174 // had keyboard focus before the modal opened and (b) restore that
175 // focus when the modal closes. Without this the user's focus is
176 // visually eclipsed by the modal but structurally remains behind it —
177 // Tab would navigate widgets on the underlying page.
178 let focus_storage = Id::new(("elegance_modal_focus", self.id_salt));
179 let mut focus_state: ModalFocusState =
180 ctx.data(|d| d.get_temp(focus_storage).unwrap_or_default());
181 let is_open = *self.open;
182
183 if focus_state.was_open && !is_open {
184 // Just closed this frame — return focus to whatever had it before.
185 if let Some(prev) = focus_state.prev_focus {
186 ctx.memory_mut(|m| m.request_focus(prev));
187 }
188 ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
189 return None;
190 }
191
192 if !is_open {
193 return None;
194 }
195
196 let just_opened = !focus_state.was_open;
197 if just_opened {
198 focus_state.prev_focus = ctx.memory(|m| m.focused());
199 focus_state.was_open = true;
200 ctx.data_mut(|d| d.insert_temp(focus_storage, focus_state));
201 }
202
203 let theme = Theme::current(ctx);
204 let p = &theme.palette;
205 let mut should_close = false;
206 let mut close_btn_id: Option<Id> = None;
207
208 // --- Backdrop ----------------------------------------------------
209 let screen = ctx.content_rect();
210 let backdrop_id = Id::new("elegance_modal_backdrop").with(self.id_salt);
211 let backdrop = Area::new(backdrop_id)
212 .fixed_pos(screen.min)
213 .order(Order::Middle)
214 .show(ctx, |ui| {
215 ui.painter().rect_filled(
216 screen,
217 CornerRadius::ZERO,
218 Color32::from_rgba_premultiplied(0, 0, 0, 150),
219 );
220 ui.allocate_rect(screen, Sense::click())
221 });
222 if self.close_on_backdrop && backdrop.inner.clicked() {
223 should_close = true;
224 }
225
226 // --- Content -----------------------------------------------------
227 let window_id = Id::new("elegance_modal_window").with(self.id_salt);
228 let alert = self.alert;
229 let heading_text: Option<String> = self.heading.as_ref().map(|h| h.text().to_string());
230 let result = Area::new(window_id)
231 .order(Order::Foreground)
232 .anchor(Align2::CENTER_CENTER, Vec2::ZERO)
233 .show(ctx, |ui| {
234 // Upgrade this Ui's accesskit role from `GenericContainer`
235 // (set automatically by `Ui::new`) to a dialog role, so
236 // screen readers announce the modal correctly and
237 // platforms that support dialog focus tracking (AT-SPI)
238 // treat it as a window-like surface.
239 let role = if alert {
240 accesskit::Role::AlertDialog
241 } else {
242 accesskit::Role::Dialog
243 };
244 let heading_for_label = heading_text.clone();
245 ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
246 node.set_role(role);
247 if let Some(label) = heading_for_label {
248 node.set_label(label);
249 }
250 });
251
252 ui.set_max_width(self.max_width);
253 Frame::new()
254 .fill(p.card)
255 .stroke(Stroke::new(1.0, p.border))
256 .corner_radius(CornerRadius::same(theme.card_radius as u8))
257 .show(ui, |ui| {
258 let pad = theme.card_padding;
259 let has_heading = self.heading.is_some();
260 let has_icon = self.header_icon.is_some();
261 if has_heading || has_icon {
262 // Header band — same horizontal padding as body,
263 // tighter bottom so the optional separator + body
264 // continue to read as one block.
265 Frame::new()
266 .inner_margin(Margin {
267 left: pad as i8,
268 right: pad as i8,
269 top: pad as i8,
270 bottom: 0,
271 })
272 .show(ui, |ui| {
273 ui.horizontal_top(|ui| {
274 if let Some(icon) = &self.header_icon {
275 paint_icon_halo(
276 ui,
277 icon.text(),
278 self.header_accent.unwrap_or(Accent::Sky),
279 &theme,
280 );
281 ui.add_space(10.0);
282 }
283 ui.vertical(|ui| {
284 if let Some(h) = &self.heading {
285 ui.add(egui::Label::new(
286 theme.heading_text(h.text()),
287 ));
288 }
289 if let Some(sub) = &self.subtitle {
290 ui.add(egui::Label::new(
291 theme.muted_text(sub.text()),
292 ));
293 }
294 });
295 ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
296 let resp = close_button(ui);
297 if resp.clicked() {
298 should_close = true;
299 }
300 close_btn_id = Some(resp.id);
301 });
302 });
303 });
304 ui.add_space(6.0);
305 ui.separator();
306 ui.add_space(10.0);
307 }
308 // --- Body ---
309 let body_result = Frame::new()
310 .inner_margin(Margin {
311 left: pad as i8,
312 right: pad as i8,
313 top: if has_heading || has_icon {
314 0
315 } else {
316 pad as i8
317 },
318 bottom: if self.footer.is_some() {
319 pad as i8 / 2
320 } else {
321 pad as i8
322 },
323 })
324 .show(ui, |ui| add_contents(ui))
325 .inner;
326
327 // --- Footer ---
328 if let Some(footer) = self.footer {
329 ui.separator();
330 Frame::new()
331 .fill(theme.palette.depth_tint(p.card, 0.04))
332 .inner_margin(Margin::symmetric(pad as i8, pad as i8 * 3 / 4))
333 .show(ui, |ui| {
334 ui.horizontal(|ui| {
335 if let Some(left) = self.footer_left {
336 left(ui);
337 }
338 ui.with_layout(
339 Layout::right_to_left(Align::Center),
340 |ui| {
341 footer(ui);
342 },
343 );
344 });
345 });
346 }
347 body_result
348 })
349 });
350
351 if self.close_on_escape && ctx.input(|i| i.key_pressed(Key::Escape)) {
352 should_close = true;
353 }
354
355 // On the first frame a modal is open, move keyboard focus into it so
356 // Tab navigates within the dialog rather than the background. We
357 // target the close button when a heading is present (it has a
358 // stable id and is always interactive); without a heading there's
359 // no intrinsic focus target, so focus is left to the caller.
360 if just_opened {
361 if let Some(id) = close_btn_id {
362 ctx.memory_mut(|m| m.request_focus(id));
363 }
364 }
365
366 if should_close {
367 *self.open = false;
368 }
369
370 Some(result.inner.inner)
371 }
372}
373
374/// Persistent focus-lifecycle state for a single `Modal`, keyed by the
375/// modal's `id_salt`. Stored via `ctx.data_mut`.
376#[derive(Clone, Copy, Default, Debug)]
377struct ModalFocusState {
378 /// Whether the modal was rendered open last frame. Used to detect
379 /// open/close transitions.
380 was_open: bool,
381 /// Which widget (if any) had keyboard focus at the moment the modal
382 /// opened. Restored on close.
383 prev_focus: Option<Id>,
384}
385
386/// Render the modal's close button. Returns its `Response` so the caller
387/// can route focus to it and check `clicked()`. The accesskit label is
388/// set to `"Close"` explicitly — without this, screen readers announce
389/// the "×" glyph literally as "multiplication sign."
390///
391/// The button is scoped under a stable id (`"elegance_modal_close"`) so
392/// focus requests targeting it survive layout changes.
393fn close_button(ui: &mut Ui) -> Response {
394 let inner = ui
395 .push_id("elegance_modal_close", |ui| {
396 ui.add(Button::new("×").outline().size(ButtonSize::Small))
397 })
398 .inner;
399 let enabled = inner.enabled();
400 inner.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, "Close"));
401 inner
402}
403
404/// Paint a circular tinted halo with a centered glyph. The fg uses the full
405/// accent colour; the bg is the same colour at low alpha so the halo reads
406/// as a coloured "wash" against the card surface.
407fn paint_icon_halo(ui: &mut Ui, glyph: &str, accent: Accent, theme: &Theme) {
408 let size = 32.0;
409 let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
410 let fg = theme.palette.accent_fill(accent);
411 let bg = Color32::from_rgba_unmultiplied(fg.r(), fg.g(), fg.b(), 36);
412 let painter = ui.painter();
413 painter.circle_filled(rect.center(), size * 0.5, bg);
414 painter.text(
415 rect.center(),
416 Align2::CENTER_CENTER,
417 glyph,
418 FontId::proportional(theme.typography.heading + 2.0),
419 fg,
420 );
421}