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}