zeus_widgets/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//!
8//! Forked from https://github.com/stephenberry/egui-elegance
9
10use egui::{
11 Align, Align2, Area, Color32, Context, CornerRadius, FontId, Frame, Id, Key, Layout, Margin,
12 Order, Pos2, Rect, Response, RichText, Sense, Shape, Stroke, Ui, Vec2, WidgetInfo, WidgetText,
13 WidgetType, accesskit, vec2,
14};
15
16use crate::Button;
17use zeus_theme::Theme;
18
19/// Boxed `FnOnce(&mut Ui)` callback used by the footer slots.
20type UiFn<'a> = Box<dyn FnOnce(&mut Ui) + 'a>;
21
22/// A centered modal dialog.
23///
24/// The `open` flag drives visibility: when it's `false` on entry to
25/// [`Modal::show`], nothing is rendered; when the user clicks the backdrop,
26/// presses `Esc`, or clicks the "×" button, it's flipped to `false`.
27///
28/// ```no_run
29/// # use zeus_widgets::Modal;
30/// # let ctx = egui::Context::default();
31/// # let mut open = true;
32/// Modal::new("stats", &mut open)
33/// .heading("Run Summary")
34/// .show(&ctx, |ui| {
35/// ui.label("…");
36/// });
37/// ```
38#[must_use = "Call `.show(ctx, |ui| { ... })` to render the modal."]
39pub struct Modal<'a> {
40 id_salt: Id,
41 heading: Option<WidgetText>,
42 subtitle: Option<WidgetText>,
43 header_icon: Option<WidgetText>,
44 backdrop_order: Order,
45 content_order: Order,
46 open: &'a mut bool,
47 max_width: f32,
48 closable: bool,
49 close_on_backdrop: bool,
50 close_on_escape: bool,
51 alert: bool,
52 footer: Option<UiFn<'a>>,
53 footer_left: Option<UiFn<'a>>,
54}
55
56impl<'a> std::fmt::Debug for Modal<'a> {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("Modal")
59 .field("id_salt", &self.id_salt)
60 .field(
61 "heading",
62 &self.heading.as_ref().map(|h| h.text()),
63 )
64 .field(
65 "subtitle",
66 &self.subtitle.as_ref().map(|h| h.text()),
67 )
68 .field(
69 "header_icon",
70 &self.header_icon.as_ref().map(|h| h.text()),
71 )
72 .field("backdrop_order", &self.backdrop_order)
73 .field("content_order", &self.content_order)
74 .field("open", &*self.open)
75 .field("max_width", &self.max_width)
76 .field("closable", &self.closable)
77 .field("close_on_backdrop", &self.close_on_backdrop)
78 .field("close_on_escape", &self.close_on_escape)
79 .field("alert", &self.alert)
80 .field(
81 "footer",
82 &self.footer.as_ref().map(|_| "<closure>"),
83 )
84 .field(
85 "footer_left",
86 &self.footer_left.as_ref().map(|_| "<closure>"),
87 )
88 .finish()
89 }
90}
91
92impl<'a> Modal<'a> {
93 /// Create a modal keyed by `id_salt` whose visibility is bound to `open`.
94 pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
95 Self {
96 id_salt: Id::new(id.into()),
97 heading: None,
98 subtitle: None,
99 header_icon: None,
100 backdrop_order: Order::Middle,
101 content_order: Order::Foreground,
102 open,
103 max_width: 440.0,
104 closable: true,
105 close_on_backdrop: true,
106 close_on_escape: true,
107 alert: false,
108 footer: None,
109 footer_left: None,
110 }
111 }
112
113 /// Show a strong heading at the top of the modal, alongside the close button.
114 pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
115 self.heading = Some(heading.into());
116 self
117 }
118
119 /// Show a muted subtitle line under the heading.
120 pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
121 self.subtitle = Some(subtitle.into());
122 self
123 }
124
125 /// Paint a glyph in a tinted circular halo to the left of the heading.
126 /// Use any short text — `"⚠"`, `"✓"`, `"!"`, an emoji, or a symbol from
127 /// the bundled `Elegance Symbols` font. The halo's tint comes from
128 /// [`Modal::header_accent`] and defaults to [`Accent::Sky`].
129 pub fn header_icon(mut self, icon: impl Into<WidgetText>) -> Self {
130 self.header_icon = Some(icon.into());
131 self
132 }
133
134 /// Override the [Order] used for the backdrop of the modal
135 pub fn backdrop_order(mut self, order: Order) -> Self {
136 self.backdrop_order = order;
137 self
138 }
139
140 /// Override the [Order] used for the content of the modal
141 pub fn content_order(mut self, order: Order) -> Self {
142 self.content_order = order;
143 self
144 }
145
146 /// Override the maximum width of the modal card in points. Default: 440.
147 pub fn max_width(mut self, max_width: f32) -> Self {
148 self.max_width = max_width;
149 self
150 }
151
152 /// Whether the user may dismiss the modal at all. When `false`, the
153 /// close "×" button is hidden and `Esc` / backdrop clicks are ignored —
154 /// regardless of [`Modal::close_on_backdrop`] / [`Modal::close_on_escape`].
155 ///
156 /// Use this to force the user to see an in-progress action through (or
157 /// cancel it via an explicit footer button) — for example, blocking
158 /// dismissal while a long-running task runs, instead of juggling the
159 /// `open` flag with an external "is it running?" guard.
160 ///
161 /// This only removes the *user-driven* dismissal affordances; the caller
162 /// is still free to set the bound `open` flag to `false` programmatically
163 /// to close the modal from code. Default: `true`.
164 pub fn closable(mut self, closable: bool) -> Self {
165 self.closable = closable;
166 self
167 }
168
169 /// Whether clicking the dimmed backdrop dismisses the modal. Default: `true`.
170 pub fn close_on_backdrop(mut self, close: bool) -> Self {
171 self.close_on_backdrop = close;
172 self
173 }
174
175 /// Whether pressing `Esc` dismisses the modal. Default: `true`.
176 pub fn close_on_escape(mut self, close: bool) -> Self {
177 self.close_on_escape = close;
178 self
179 }
180
181 /// Mark this modal as an *alert dialog* — a dialog that demands the
182 /// user's attention to proceed, such as a destructive confirmation or
183 /// an unsaved-changes prompt. Screen readers announce alert dialogs
184 /// more assertively than ordinary dialogs. Default: `false`.
185 ///
186 /// Under the hood this exposes `accesskit::Role::AlertDialog` on the
187 /// modal's root node instead of the default `Role::Dialog`.
188 pub fn alert(mut self, alert: bool) -> Self {
189 self.alert = alert;
190 self
191 }
192
193 /// Add a footer row at the bottom of the modal. The closure runs in a
194 /// right-to-left layout, so widgets added in source order land
195 /// rightmost-first — matching the typical "Cancel | Confirm" reading.
196 /// The footer renders below a horizontal divider and over a slightly
197 /// recessed fill, separating it visually from the body.
198 pub fn footer<F: FnOnce(&mut Ui) + 'a>(mut self, add_footer: F) -> Self {
199 self.footer = Some(Box::new(add_footer));
200 self
201 }
202
203 /// Add a left-aligned slot to the footer (only rendered when
204 /// [`Modal::footer`] is also set). Useful for an "export before delete"
205 /// checkbox or a keyboard-shortcut hint that should sit opposite the
206 /// action buttons.
207 pub fn footer_left<F: FnOnce(&mut Ui) + 'a>(mut self, add_left: F) -> Self {
208 self.footer_left = Some(Box::new(add_left));
209 self
210 }
211
212 /// Render the modal. Returns `None` if the modal was suppressed because
213 /// the bound `open` flag was `false`; otherwise returns `Some(R)` with
214 /// the content closure's return value.
215 pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
216 // --- Focus lifecycle ------------------------------------------------
217 // Track the open/closed transition so we can (a) record which widget
218 // had keyboard focus before the modal opened and (b) restore that
219 // focus when the modal closes. Without this the user's focus is
220 // visually eclipsed by the modal but structurally remains behind it —
221 // Tab would navigate widgets on the underlying page.
222 let focus_storage = Id::new(("elegance_modal_focus", self.id_salt));
223 let mut focus_state: ModalFocusState =
224 ctx.data(|d| d.get_temp(focus_storage).unwrap_or_default());
225 let is_open = *self.open;
226
227 if focus_state.was_open && !is_open {
228 // Just closed this frame — return focus to whatever had it before.
229 if let Some(prev) = focus_state.prev_focus {
230 ctx.memory_mut(|m| m.request_focus(prev));
231 }
232 ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
233 return None;
234 }
235
236 if !is_open {
237 return None;
238 }
239
240 let just_opened = !focus_state.was_open;
241 if just_opened {
242 focus_state.prev_focus = ctx.memory(|m| m.focused());
243 focus_state.was_open = true;
244 ctx.data_mut(|d| d.insert_temp(focus_storage, focus_state));
245 }
246
247 let theme = Theme::current(ctx);
248 let mut should_close = false;
249 let mut close_btn_id: Option<Id> = None;
250 let closable = self.closable;
251
252 // --- Backdrop ----------------------------------------------------
253 let screen = ctx.content_rect();
254 let backdrop_id = Id::new("elegance_modal_backdrop").with(self.id_salt);
255 let backdrop =
256 Area::new(backdrop_id)
257 .fixed_pos(screen.min)
258 .order(self.backdrop_order)
259 .show(ctx, |ui| {
260 ui.painter().rect_filled(
261 screen,
262 CornerRadius::ZERO,
263 Color32::from_rgba_premultiplied(0, 0, 0, 150),
264 );
265 ui.allocate_rect(screen, Sense::click())
266 });
267 if closable && self.close_on_backdrop && backdrop.inner.clicked() {
268 should_close = true;
269 }
270
271 // --- Content -----------------------------------------------------
272 let window_id = Id::new("elegance_modal_window").with(self.id_salt);
273 let alert = self.alert;
274 let heading_text: Option<String> = self.heading.as_ref().map(|h| h.text().to_string());
275 let result = Area::new(window_id)
276 .order(self.content_order)
277 .anchor(Align2::CENTER_CENTER, Vec2::ZERO)
278 .show(ctx, |ui| {
279 // Upgrade this Ui's accesskit role from `GenericContainer`
280 // (set automatically by `Ui::new`) to a dialog role, so
281 // screen readers announce the modal correctly and
282 // platforms that support dialog focus tracking (AT-SPI)
283 // treat it as a window-like surface.
284 let role = if alert {
285 accesskit::Role::AlertDialog
286 } else {
287 accesskit::Role::Dialog
288 };
289 let heading_for_label = heading_text.clone();
290 ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
291 node.set_role(role);
292 if let Some(label) = heading_for_label {
293 node.set_label(label);
294 }
295 });
296
297 ui.set_max_width(self.max_width);
298 Frame::new()
299 .fill(theme.colors.widget_bg)
300 .stroke(Stroke::new(1.0, theme.colors.border))
301 .corner_radius(theme.frame1.corner_radius)
302 .show(ui, |ui| {
303 let pad = theme.frame1.inner_margin.left;
304 let has_heading = self.heading.is_some();
305 let has_icon = self.header_icon.is_some();
306 if has_heading || has_icon {
307 // Header band — same horizontal padding as body,
308 // tighter bottom so the optional separator + body
309 // continue to read as one block.
310 Frame::new()
311 .inner_margin(Margin {
312 left: pad as i8,
313 right: pad as i8,
314 top: pad as i8,
315 bottom: 0,
316 })
317 .show(ui, |ui| {
318 ui.horizontal_top(|ui| {
319 if let Some(icon) = &self.header_icon {
320 paint_icon_halo(ui, icon.text(), &theme);
321 ui.add_space(10.0);
322 }
323 ui.vertical(|ui| {
324 if let Some(h) = &self.heading {
325 ui.add(egui::Label::new(h.clone()));
326 }
327 if let Some(sub) = &self.subtitle {
328 ui.add(egui::Label::new(sub.clone()));
329 }
330 });
331 ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
332 // A non-closable modal shows no "×":
333 // there's no user-driven way out, so
334 // an affordance would only mislead.
335 if closable {
336 let resp = close_button(ui, &theme);
337 if resp.clicked() {
338 should_close = true;
339 }
340 close_btn_id = Some(resp.id);
341 }
342 });
343 });
344 });
345 ui.add_space(6.0);
346 ui.separator();
347 ui.add_space(10.0);
348 }
349 // --- Body ---
350 let body_result = Frame::new()
351 .inner_margin(Margin {
352 left: pad as i8,
353 right: pad as i8,
354 top: if has_heading || has_icon {
355 0
356 } else {
357 pad as i8
358 },
359 bottom: if self.footer.is_some() {
360 pad as i8 / 2
361 } else {
362 pad as i8
363 },
364 })
365 .show(ui, |ui| add_contents(ui))
366 .inner;
367
368 // --- Footer ---
369 if let Some(footer) = self.footer {
370 ui.separator();
371 // The recessed footer fill is painted by hand rather
372 // than via the frame's own `.fill`. A plain frame
373 // fill is a square-cornered rectangle flush with the
374 // card edges, so it paints over the card's rounded
375 // bottom corners and bottom border — the non-round
376 // corners reported in issue #7. Instead we lay the
377 // footer out with no fill, then drop a rounded fill
378 // into a slot reserved *behind* the content, tucked
379 // one pixel inside the 1px border so the border (and
380 // its rounded corners) stays unbroken all the way
381 // around.
382 let footer_fill = theme.colors.widget_bg;
383 let fill_idx = ui.painter().add(Shape::Noop);
384 let footer_rect = Frame::new()
385 .inner_margin(Margin::symmetric(pad as i8, pad as i8 * 3 / 4))
386 .show(ui, |ui| {
387 ui.horizontal(|ui| {
388 if let Some(left) = self.footer_left {
389 left(ui);
390 }
391 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
392 footer(ui);
393 });
394 });
395 })
396 .response
397 .rect;
398 // Round the bottom corners one pixel tighter than the
399 // card so the fill follows the inside of the border's
400 // curve; leave the top flush with the divider above.
401 let card_radius = theme.frame1.corner_radius.ne as f32;
402 let r = (card_radius - 1.0).max(0.0) as u8;
403 let fill_rect = Rect::from_min_max(
404 Pos2::new(footer_rect.left() + 1.0, footer_rect.top()),
405 Pos2::new(
406 footer_rect.right() - 1.0,
407 footer_rect.bottom() - 1.0,
408 ),
409 );
410 ui.painter().set(
411 fill_idx,
412 Shape::rect_filled(
413 fill_rect,
414 CornerRadius {
415 nw: 0,
416 ne: 0,
417 sw: r,
418 se: r,
419 },
420 footer_fill,
421 ),
422 );
423 }
424 body_result
425 })
426 });
427
428 if closable && self.close_on_escape && ctx.input(|i| i.key_pressed(Key::Escape)) {
429 should_close = true;
430 }
431
432 // On the first frame a modal is open, move keyboard focus into it so
433 // Tab navigates within the dialog rather than the background. We
434 // target the close button when a heading is present (it has a
435 // stable id and is always interactive); without a heading there's
436 // no intrinsic focus target, so focus is left to the caller.
437 if just_opened && let Some(id) = close_btn_id {
438 ctx.memory_mut(|m| m.request_focus(id));
439 }
440
441 if should_close {
442 *self.open = false;
443 // Restore focus and clear the lifecycle state right now — in the
444 // same frame the close is triggered — rather than deferring to the
445 // `was_open && !is_open` branch on a subsequent `show()`. Callers
446 // routinely drop the modal the instant it closes (the natural
447 // `if let Some(m) = &self.modal { … }` + `self.modal = None`
448 // pattern), so `show()` is never called again. A deferred cleanup
449 // would then silently leak: focus is never returned to the
450 // pre-modal widget, and the stale `was_open = true` defeats the
451 // just-opened focus grab the next time a modal with this salt opens.
452 if let Some(prev) = focus_state.prev_focus {
453 ctx.memory_mut(|m| m.request_focus(prev));
454 }
455 ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
456 }
457
458 Some(result.inner.inner)
459 }
460}
461
462/// Persistent focus-lifecycle state for a single `Modal`, keyed by the
463/// modal's `id_salt`. Stored via `ctx.data_mut`.
464#[derive(Clone, Copy, Default, Debug)]
465struct ModalFocusState {
466 /// Whether the modal was rendered open last frame. Used to detect
467 /// open/close transitions.
468 was_open: bool,
469 /// Which widget (if any) had keyboard focus at the moment the modal
470 /// opened. Restored on close.
471 prev_focus: Option<Id>,
472}
473
474/// Render the modal's close button. Returns its `Response` so the caller
475/// can route focus to it and check `clicked()`. The accesskit label is
476/// set to `"Close"` explicitly — without this, screen readers announce
477/// the "×" glyph literally as "multiplication sign."
478///
479/// The button is scoped under a stable id (`"elegance_modal_close"`) so
480/// focus requests targeting it survive layout changes.
481fn close_button(ui: &mut Ui, theme: &Theme) -> Response {
482 let inner = ui
483 .push_id("modal_close", |ui| {
484 let text = RichText::new("X").size(theme.text_sizes.normal);
485 ui.add(Button::new(text).min_size(vec2(20.0, 20.0)))
486 })
487 .inner;
488 let enabled = inner.enabled();
489 inner.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, "Close"));
490 inner
491}
492
493/// Paint a circular tinted halo with a centered glyph. The fg uses the full
494/// accent colour; the bg is the same colour at low alpha so the halo reads
495/// as a coloured "wash" against the card surface.
496fn paint_icon_halo(ui: &mut Ui, glyph: &str, theme: &Theme) {
497 let size = 32.0;
498 let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
499 let fg = theme.colors.accent;
500 let bg = Color32::from_rgba_unmultiplied(fg.r(), fg.g(), fg.b(), 36);
501 let painter = ui.painter();
502 painter.circle_filled(rect.center(), size * 0.5, bg);
503 painter.text(
504 rect.center(),
505 Align2::CENTER_CENTER,
506 glyph,
507 FontId::proportional(theme.text_sizes.heading + 2.0),
508 fg,
509 );
510}