Skip to main content

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}