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