Skip to main content

egui_elements/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-like
5//! 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, Frame, Id, Image, Key, Layout, Margin,
12   Order, Pos2, Rect, Response, RichText, Sense, Shape, Stroke, Ui, UiBuilder, Vec2, WidgetInfo,
13   WidgetText, WidgetType, accesskit, vec2,
14};
15
16use super::button::Button;
17use crate::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 egui_elements::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<Image<'a>>,
44   frame: Option<Frame>,
45   center_header_icon: bool,
46   backdrop_order: Order,
47   content_order: Order,
48   open: &'a mut bool,
49   max_width: Option<f32>,
50   closable: bool,
51   close_on_backdrop: bool,
52   close_on_escape: bool,
53   alert: bool,
54   center_header: bool,
55   header_separator: bool,
56   footer_separator: bool,
57   footer: Option<UiFn<'a>>,
58   footer_left: Option<UiFn<'a>>,
59}
60
61impl<'a> std::fmt::Debug for Modal<'a> {
62   fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63      f.debug_struct("Modal")
64         .field("id_salt", &self.id_salt)
65         .field(
66            "heading",
67            &self.heading.as_ref().map(|h| h.text()),
68         )
69         .field(
70            "subtitle",
71            &self.subtitle.as_ref().map(|h| h.text()),
72         )
73         .field(
74            "header_icon",
75            &self.header_icon.as_ref().map(|_| "<image>"),
76         )
77         .field("frame", &self.frame)
78         .field("center_header_icon", &self.center_header_icon)
79         .field("backdrop_order", &self.backdrop_order)
80         .field("content_order", &self.content_order)
81         .field("open", &*self.open)
82         .field("max_width", &self.max_width)
83         .field("closable", &self.closable)
84         .field("close_on_backdrop", &self.close_on_backdrop)
85         .field("close_on_escape", &self.close_on_escape)
86         .field("alert", &self.alert)
87         .field("center_header", &self.center_header)
88         .field("header_separator", &self.header_separator)
89         .field("footer_separator", &self.footer_separator)
90         .field(
91            "footer",
92            &self.footer.as_ref().map(|_| "<closure>"),
93         )
94         .field(
95            "footer_left",
96            &self.footer_left.as_ref().map(|_| "<closure>"),
97         )
98         .finish()
99   }
100}
101
102impl<'a> Modal<'a> {
103   /// Create a modal keyed by `id_salt` whose visibility is bound to `open`.
104   pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
105      Self {
106         id_salt: Id::new(id.into()),
107         heading: None,
108         subtitle: None,
109         header_icon: None,
110         frame: None,
111         center_header_icon: false,
112         backdrop_order: Order::Middle,
113         content_order: Order::Foreground,
114         open,
115         max_width: None,
116         closable: true,
117         close_on_backdrop: true,
118         close_on_escape: true,
119         alert: false,
120         center_header: false,
121         header_separator: true,
122         footer_separator: true,
123         footer: None,
124         footer_left: None,
125      }
126   }
127
128   /// Show a strong heading at the top of the modal, alongside the close button.
129   pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
130      self.heading = Some(heading.into());
131      self
132   }
133
134   /// Show a muted subtitle line under the heading.
135   pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
136      self.subtitle = Some(subtitle.into());
137      self
138   }
139
140   /// Show an image in the header. Alignment is independent of the title
141   /// — see [`Modal::center_header_icon`]. Pass a sized/tinted
142   /// [`egui::Image`], for example a Lucide SVG:
143   ///
144   /// ```ignore
145   /// .header_icon(Lucide::ShieldCheck.size(28.0).color(theme.colors.accent).image())
146   /// ```
147   pub fn header_icon(mut self, icon: impl Into<Image<'a>>) -> Self {
148      self.header_icon = Some(icon.into());
149      self
150   }
151
152   /// Override the [Frame] used for the modal's card.
153   ///
154   /// By default it creates a frame from the theme's [Theme::current].
155   pub fn frame(mut self, frame: Frame) -> Self {
156      self.frame = Some(frame);
157      self
158   }
159
160   /// Center the header image on its own row. Default: `false` (left).
161   /// Does not change title alignment — that is [`Modal::center_header`].
162   pub fn center_header_icon(mut self, center: bool) -> Self {
163      self.center_header_icon = center;
164      self
165   }
166
167   /// Override the [Order] used for the backdrop of the modal
168   pub fn backdrop_order(mut self, order: Order) -> Self {
169      self.backdrop_order = order;
170      self
171   }
172
173   /// Override the [Order] used for the content of the modal
174   pub fn content_order(mut self, order: Order) -> Self {
175      self.content_order = order;
176      self
177   }
178
179   /// Cap the modal card width in points. When unset the card shrink-wraps
180   /// to its contents and stays centered. Default: no cap.
181   pub fn max_width(mut self, max_width: f32) -> Self {
182      self.max_width = Some(max_width);
183      self
184   }
185
186   /// Whether the user may dismiss the modal at all. When `false`, the
187   /// close "×" button is hidden and `Esc` / backdrop clicks are ignored —
188   /// regardless of [`Modal::close_on_backdrop`] / [`Modal::close_on_escape`].
189   ///
190   /// Use this to force the user to see an in-progress action through (or
191   /// cancel it via an explicit footer button) — for example, blocking
192   /// dismissal while a long-running task runs, instead of juggling the
193   /// `open` flag with an external "is it running?" guard.
194   ///
195   /// This only removes the *user-driven* dismissal affordances; the caller
196   /// is still free to set the bound `open` flag to `false` programmatically
197   /// to close the modal from code. Default: `true`.
198   pub fn closable(mut self, closable: bool) -> Self {
199      self.closable = closable;
200      self
201   }
202
203   /// Whether clicking the dimmed backdrop dismisses the modal. Default: `true`.
204   pub fn close_on_backdrop(mut self, close: bool) -> Self {
205      self.close_on_backdrop = close;
206      self
207   }
208
209   /// Whether pressing `Esc` dismisses the modal. Default: `true`.
210   pub fn close_on_escape(mut self, close: bool) -> Self {
211      self.close_on_escape = close;
212      self
213   }
214
215   /// Mark this modal as an *alert dialog* — a dialog that demands the
216   /// user's attention to proceed, such as a destructive confirmation or
217   /// an unsaved-changes prompt. Screen readers announce alert dialogs
218   /// more assertively than ordinary dialogs. Default: `false`.
219   ///
220   /// Under the hood this exposes `accesskit::Role::AlertDialog` on the
221   /// modal's root node instead of the default `Role::Dialog`.
222   pub fn alert(mut self, alert: bool) -> Self {
223      self.alert = alert;
224      self
225   }
226
227   /// Center the heading and subtitle in the header band. The close
228   /// button stays top-right. Default: `false` (left-aligned). Icon
229   /// alignment is separate — see [`Modal::center_header_icon`].
230   pub fn center_header(mut self, center: bool) -> Self {
231      self.center_header = center;
232      self
233   }
234
235   /// Draw a horizontal divider under the header. Default: `true`.
236   pub fn header_separator(mut self, show: bool) -> Self {
237      self.header_separator = show;
238      self
239   }
240
241   /// Draw a horizontal divider above the footer. Default: `true`.
242   pub fn footer_separator(mut self, show: bool) -> Self {
243      self.footer_separator = show;
244      self
245   }
246
247   /// Add a footer row at the bottom of the modal. The closure runs in a
248   /// right-to-left layout, so widgets added in source order land
249   /// rightmost-first — matching the typical "Cancel | Confirm" reading.
250   /// The footer renders below an optional horizontal divider (see
251   /// [`Modal::footer_separator`]) and over a slightly recessed fill,
252   /// separating it visually from the body.
253   pub fn footer<F: FnOnce(&mut Ui) + 'a>(mut self, add_footer: F) -> Self {
254      self.footer = Some(Box::new(add_footer));
255      self
256   }
257
258   /// Add a left-aligned slot to the footer (only rendered when
259   /// [`Modal::footer`] is also set). Useful for an "export before delete"
260   /// checkbox or a keyboard-shortcut hint that should sit opposite the
261   /// action buttons.
262   pub fn footer_left<F: FnOnce(&mut Ui) + 'a>(mut self, add_left: F) -> Self {
263      self.footer_left = Some(Box::new(add_left));
264      self
265   }
266
267   /// Render the modal. Returns `None` if the modal was suppressed because
268   /// the bound `open` flag was `false`; otherwise returns `Some(R)` with
269   /// the content closure's return value.
270   pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
271      // --- Focus lifecycle ------------------------------------------------
272      // Track the open/closed transition so we can (a) record which widget
273      // had keyboard focus before the modal opened and (b) restore that
274      // focus when the modal closes. Without this the user's focus is
275      // visually eclipsed by the modal but structurally remains behind it —
276      // Tab would navigate widgets on the underlying page.
277      let focus_storage = Id::new(("elegance_modal_focus", self.id_salt));
278      let mut focus_state: ModalFocusState =
279         ctx.data(|d| d.get_temp(focus_storage).unwrap_or_default());
280      let is_open = *self.open;
281
282      if focus_state.was_open && !is_open {
283         // Just closed this frame — return focus to whatever had it before.
284         if let Some(prev) = focus_state.prev_focus {
285            ctx.memory_mut(|m| m.request_focus(prev));
286         }
287         ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
288         return None;
289      }
290
291      if !is_open {
292         return None;
293      }
294
295      let just_opened = !focus_state.was_open;
296      if just_opened {
297         focus_state.prev_focus = ctx.memory(|m| m.focused());
298         focus_state.was_open = true;
299         ctx.data_mut(|d| d.insert_temp(focus_storage, focus_state));
300      }
301
302      let theme = Theme::current(ctx);
303      let mut should_close = false;
304      let mut close_btn_id: Option<Id> = None;
305      let closable = self.closable;
306
307      // --- Backdrop ----------------------------------------------------
308      let screen = ctx.content_rect();
309      let backdrop_id = Id::new("elegance_modal_backdrop").with(self.id_salt);
310      let backdrop =
311         Area::new(backdrop_id)
312            .fixed_pos(screen.min)
313            .order(self.backdrop_order)
314            .show(ctx, |ui| {
315               ui.painter().rect_filled(
316                  screen,
317                  CornerRadius::ZERO,
318                  Color32::from_rgba_premultiplied(0, 0, 0, 150),
319               );
320               ui.allocate_rect(screen, Sense::click())
321            });
322      if closable && self.close_on_backdrop && backdrop.inner.clicked() {
323         should_close = true;
324      }
325
326      // --- Content -----------------------------------------------------
327      let window_id = Id::new("elegance_modal_window").with(self.id_salt);
328      let alert = self.alert;
329      let heading_text: Option<String> = self.heading.as_ref().map(|h| h.text().to_string());
330      let mut area = Area::new(window_id)
331         .order(self.content_order)
332         .anchor(Align2::CENTER_CENTER, Vec2::ZERO)
333         .pivot(Align2::CENTER_CENTER);
334      // 0 = shrink-wrap the first frame so a missing max_width still
335      // measures true content width; Area re-anchors on the next pass.
336      area = area.default_width(self.max_width.unwrap_or(0.0));
337      let result = area.show(ctx, |ui| {
338         // Upgrade this Ui's accesskit role from `GenericContainer`
339         // (set automatically by `Ui::new`) to a dialog role, so
340         // screen readers announce the modal correctly and
341         // platforms that support dialog focus tracking (AT-SPI)
342         // treat it as a window-like surface.
343         let role = if alert {
344            accesskit::Role::AlertDialog
345         } else {
346            accesskit::Role::Dialog
347         };
348         let heading_for_label = heading_text.clone();
349         ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
350            node.set_role(role);
351            if let Some(label) = heading_for_label {
352               node.set_label(label);
353            }
354         });
355
356         if let Some(max_width) = self.max_width {
357            ui.set_max_width(max_width);
358         }
359
360         let frame = self.frame.unwrap_or_else(|| {
361            Frame::new()
362               .fill(theme.colors.bg)
363               .stroke(Stroke::new(1.0, theme.colors.border))
364               .corner_radius(theme.frame1.corner_radius)
365         });
366
367         let card = frame.show(ui, |ui| {
368            let pad = theme.frame1.inner_margin.left;
369            let has_heading = self.heading.is_some();
370            let has_icon = self.header_icon.is_some();
371            if has_heading || has_icon {
372               // Header band — same horizontal padding as body,
373               // tighter bottom so the optional separator + body
374               // continue to read as one block.
375               Frame::new()
376                  .inner_margin(Margin {
377                     left: pad as i8,
378                     right: pad as i8,
379                     top: pad as i8,
380                     bottom: 0,
381                  })
382                  .show(ui, |ui| {
383                     let needs_full_width = self.center_header || self.center_header_icon;
384                     if needs_full_width {
385                        ui.set_min_width(ui.available_width());
386                     }
387
388                     let stacked_icon = has_icon && (self.center_header_icon || self.center_header);
389                     if stacked_icon {
390                        if let Some(icon) = self.header_icon.clone() {
391                           if self.center_header_icon {
392                              ui.vertical_centered(|ui| {
393                                 ui.add(icon);
394                              });
395                           } else {
396                              ui.add(icon);
397                           }
398                        }
399                     }
400
401                     if self.center_header {
402                        ui.vertical_centered(|ui| {
403                           if let Some(h) = &self.heading {
404                              ui.add(egui::Label::new(h.clone()).halign(Align::Center));
405                           }
406                           if let Some(sub) = &self.subtitle {
407                              ui.add(egui::Label::new(sub.clone()).halign(Align::Center));
408                           }
409                        });
410                     } else {
411                        ui.horizontal_top(|ui| {
412                           if has_icon && !stacked_icon {
413                              if let Some(icon) = &self.header_icon {
414                                 ui.add(icon.clone());
415                                 ui.add_space(10.0);
416                              }
417                           }
418                           ui.vertical(|ui| {
419                              if let Some(h) = &self.heading {
420                                 ui.add(egui::Label::new(h.clone()));
421                              }
422                              if let Some(sub) = &self.subtitle {
423                                 ui.add(egui::Label::new(sub.clone()));
424                              }
425                           });
426                           ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
427                              // A non-closable modal shows no "×":
428                              // there's no user-driven way out, so
429                              // an affordance would only mislead.
430                              if closable {
431                                 let resp = close_button(ui, &theme);
432                                 if resp.clicked() {
433                                    should_close = true;
434                                 }
435                                 close_btn_id = Some(resp.id);
436                              }
437                           });
438                        });
439                     }
440                  });
441               if self.header_separator {
442                  ui.add_space(6.0);
443                  ui.separator();
444                  ui.add_space(10.0);
445               } else {
446                  ui.add_space(10.0);
447               }
448            }
449            // --- Body ---
450            let body_result = Frame::new()
451               .inner_margin(Margin {
452                  left: pad as i8,
453                  right: pad as i8,
454                  top: if has_heading || has_icon {
455                     0
456                  } else {
457                     pad as i8
458                  },
459                  bottom: if self.footer.is_some() {
460                     pad as i8 / 2
461                  } else {
462                     pad as i8
463                  },
464               })
465               .show(ui, |ui| add_contents(ui))
466               .inner;
467
468            // --- Footer ---
469            if let Some(footer) = self.footer {
470               if self.footer_separator {
471                  ui.separator();
472               }
473               // The recessed footer fill is painted by hand rather
474               // than via the frame's own `.fill`. A plain frame
475               // fill is a square-cornered rectangle flush with the
476               // card edges, so it paints over the card's rounded
477               // bottom corners and bottom border — the non-round
478               // corners reported in issue #7. Instead we lay the
479               // footer out with no fill, then drop a rounded fill
480               // into a slot reserved *behind* the content, tucked
481               // one pixel inside the 1px border so the border (and
482               // its rounded corners) stays unbroken all the way
483               // around.
484               let footer_fill = theme.colors.widget_bg;
485               let fill_idx = ui.painter().add(Shape::Noop);
486               let footer_rect = Frame::new()
487                  .fill(theme.colors.bg)
488                  .inner_margin(Margin::symmetric(pad as i8, pad as i8 * 3 / 4))
489                  .show(ui, |ui| {
490                     ui.horizontal(|ui| {
491                        if let Some(left) = self.footer_left {
492                           left(ui);
493                        }
494                        ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
495                           footer(ui);
496                        });
497                     });
498                  })
499                  .response
500                  .rect;
501               // Round the bottom corners one pixel tighter than the
502               // card so the fill follows the inside of the border's
503               // curve; leave the top flush with the divider above.
504               let card_radius = theme.frame1.corner_radius.ne as f32;
505               let r = (card_radius - 1.0).max(0.0) as u8;
506               let fill_rect = Rect::from_min_max(
507                  Pos2::new(footer_rect.left() + 1.0, footer_rect.top()),
508                  Pos2::new(
509                     footer_rect.right() - 1.0,
510                     footer_rect.bottom() - 1.0,
511                  ),
512               );
513               ui.painter().set(
514                  fill_idx,
515                  Shape::rect_filled(
516                     fill_rect,
517                     CornerRadius {
518                        nw: 0,
519                        ne: 0,
520                        sw: r,
521                        se: r,
522                     },
523                     footer_fill,
524                  ),
525               );
526            }
527            body_result
528         });
529
530         let card_rect = card.response.rect;
531         ui.expand_to_include_rect(card_rect);
532         #[cfg(test)]
533         ui.ctx().data_mut(|d| {
534            d.insert_temp(Id::new("egui_elements_test_card_rect"), card_rect);
535         });
536
537         // Centered headers skip the in-flow close button so a wide
538         // body can size the card first. Pin × to that final corner.
539         if closable && self.center_header {
540            let pad = theme.frame1.inner_margin.left as f32;
541            let close_rect = Rect::from_min_size(
542               Pos2::new(
543                  card_rect.right() - pad - HEADER_CLOSE_SLOT,
544                  card_rect.top() + pad,
545               ),
546               vec2(HEADER_CLOSE_SLOT, HEADER_CLOSE_SLOT),
547            );
548            ui.scope_builder(
549               UiBuilder::new().max_rect(close_rect).layout(Layout::right_to_left(Align::Min)),
550               |ui| {
551                  let resp = close_button(ui, &theme);
552                  if resp.clicked() {
553                     should_close = true;
554                  }
555                  close_btn_id = Some(resp.id);
556               },
557            );
558         }
559         card.inner
560      });
561
562      if closable && self.close_on_escape && ctx.input(|i| i.key_pressed(Key::Escape)) {
563         should_close = true;
564      }
565
566      // On the first frame a modal is open, move keyboard focus into it so
567      // Tab navigates within the dialog rather than the background. We
568      // target the close button when a heading is present (it has a
569      // stable id and is always interactive); without a heading there's
570      // no intrinsic focus target, so focus is left to the caller.
571      if just_opened && let Some(id) = close_btn_id {
572         ctx.memory_mut(|m| m.request_focus(id));
573      }
574
575      if should_close {
576         *self.open = false;
577         // Restore focus and clear the lifecycle state right now — in the
578         // same frame the close is triggered — rather than deferring to the
579         // `was_open && !is_open` branch on a subsequent `show()`. Callers
580         // routinely drop the modal the instant it closes (the natural
581         // `if let Some(m) = &self.modal { … }` + `self.modal = None`
582         // pattern), so `show()` is never called again. A deferred cleanup
583         // would then silently leak: focus is never returned to the
584         // pre-modal widget, and the stale `was_open = true` defeats the
585         // just-opened focus grab the next time a modal with this salt opens.
586         if let Some(prev) = focus_state.prev_focus {
587            ctx.memory_mut(|m| m.request_focus(prev));
588         }
589         ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
590      }
591
592      Some(result.inner)
593   }
594}
595
596/// Persistent focus-lifecycle state for a single `Modal`, keyed by the
597/// modal's `id_salt`. Stored via `ctx.data_mut`.
598#[derive(Clone, Copy, Default, Debug)]
599struct ModalFocusState {
600   /// Whether the modal was rendered open last frame. Used to detect
601   /// open/close transitions.
602   was_open: bool,
603   /// Which widget (if any) had keyboard focus at the moment the modal
604   /// opened. Restored on close.
605   prev_focus: Option<Id>,
606}
607
608/// Overlay slot for the close button in a centered header. Sized to
609/// fit the 20×20 min button plus theme padding without clipping.
610const HEADER_CLOSE_SLOT: f32 = 48.0;
611
612/// Render the modal's close button. Returns its `Response` so the caller
613/// can route focus to it and check `clicked()`. The accesskit label is
614/// set to `"Close"` explicitly — without this, screen readers announce
615/// the "×" glyph literally as "multiplication sign."
616///
617/// The button is scoped under a stable id (`"elegance_modal_close"`) so
618/// focus requests targeting it survive layout changes.
619fn close_button(ui: &mut Ui, theme: &Theme) -> Response {
620   let inner = ui
621      .push_id("modal_close", |ui| {
622         let text = RichText::new("X").size(theme.typography.normal);
623         let padding = vec2(theme.spacing.sm, theme.spacing.xs);
624         ui.spacing_mut().button_padding = padding;
625         ui.add(Button::new(text).min_size(vec2(20.0, 20.0)))
626      })
627      .inner;
628   #[cfg(test)]
629   ui.ctx().data_mut(|d| {
630      d.insert_temp(
631         Id::new("egui_elements_test_close_rect"),
632         inner.rect,
633      );
634   });
635   let enabled = inner.enabled();
636   inner.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, "Close"));
637   inner
638}
639
640#[cfg(test)]
641mod tests {
642   use super::*;
643
644   #[test]
645   fn center_header_without_separators_renders() {
646      egui::__run_test_ctx(|ctx| {
647         let mut open = true;
648         Modal::new("modal_opts", &mut open)
649            .heading("Title")
650            .subtitle("Subtitle")
651            .header_icon(Image::new("file://modal_icon"))
652            .center_header(true)
653            .center_header_icon(true)
654            .header_separator(false)
655            .footer_separator(false)
656            .footer(|ui| {
657               ui.label("ok");
658            })
659            .show(ctx, |ui| {
660               ui.label("body");
661            });
662         assert!(open);
663      });
664   }
665
666   #[test]
667   fn default_header_layout_still_renders() {
668      egui::__run_test_ctx(|ctx| {
669         let mut open = true;
670         Modal::new("modal_default", &mut open)
671            .heading("Title")
672            .footer(|ui| {
673               ui.label("ok");
674            })
675            .show(ctx, |ui| {
676               ui.label("body");
677            });
678         assert!(open);
679      });
680   }
681
682   #[test]
683   fn centered_close_button_hugs_card_right() {
684      egui::__run_test_ctx(|ctx| {
685         let mut open = true;
686         Modal::new("modal_corner", &mut open)
687            .heading("Title")
688            .center_header(true)
689            .max_width(500.0)
690            .show(ctx, |ui| {
691               ui.set_min_width(480.0);
692               ui.label("wide body");
693            });
694         let close: Rect = ctx
695            .data(|d| d.get_temp(Id::new("egui_elements_test_close_rect")))
696            .expect("close button rect");
697         let card: Rect = ctx
698            .data(|d| d.get_temp(Id::new("egui_elements_test_card_rect")))
699            .expect("card rect");
700         let gap = card.right() - close.right();
701         assert!(
702            gap >= 0.0 && gap < 56.0,
703            "close button should hug the card's right edge, gap={gap}, close={close:?}, card={card:?}"
704         );
705         assert!(
706            close.right() > card.center().x,
707            "close button should be on the right half of the card"
708         );
709      });
710   }
711
712   fn run_two_passes(mut f: impl FnMut(&Context)) {
713      let ctx = Context::default();
714      ctx.set_fonts(egui::FontDefinitions::empty());
715      for _ in 0..2 {
716         ctx.run_ui(Default::default(), |ui| f(ui.ctx())).drop_without_applying_deltas();
717      }
718   }
719
720   #[test]
721   fn modal_centers_without_max_width() {
722      let mut last: Option<(Rect, Rect)> = None;
723      run_two_passes(|ctx| {
724         let mut open = true;
725         Modal::new("modal_center", &mut open).heading("Title").show(ctx, |ui| {
726            ui.set_min_width(520.0);
727            ui.label("wide body");
728         });
729         let card: Rect = ctx
730            .data(|d| d.get_temp(Id::new("egui_elements_test_card_rect")))
731            .expect("card rect");
732         last = Some((card, ctx.content_rect()));
733      });
734      let (card, screen) = last.expect("second pass");
735      let dx = (card.center().x - screen.center().x).abs();
736      let dy = (card.center().y - screen.center().y).abs();
737      assert!(
738         dx < 8.0 && dy < 8.0,
739         "card should be centered without max_width, dx={dx}, dy={dy}, card={card:?}, screen={screen:?}"
740      );
741   }
742
743   #[test]
744   fn modal_centers_with_max_width() {
745      let mut last: Option<(Rect, Rect)> = None;
746      run_two_passes(|ctx| {
747         let mut open = true;
748         Modal::new("modal_center_cap", &mut open)
749            .heading("Title")
750            .max_width(600.0)
751            .show(ctx, |ui| {
752               ui.set_min_width(520.0);
753               ui.label("wide body");
754            });
755         let card: Rect = ctx
756            .data(|d| d.get_temp(Id::new("egui_elements_test_card_rect")))
757            .expect("card rect");
758         last = Some((card, ctx.content_rect()));
759      });
760      let (card, screen) = last.expect("second pass");
761      let dx = (card.center().x - screen.center().x).abs();
762      let dy = (card.center().y - screen.center().y).abs();
763      assert!(
764         dx < 8.0 && dy < 8.0,
765         "card should be centered with max_width, dx={dx}, dy={dy}, card={card:?}, screen={screen:?}"
766      );
767   }
768}