1use 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
19type UiFn<'a> = Box<dyn FnOnce(&mut Ui) + 'a>;
21
22#[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 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 pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
130 self.heading = Some(heading.into());
131 self
132 }
133
134 pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
136 self.subtitle = Some(subtitle.into());
137 self
138 }
139
140 pub fn header_icon(mut self, icon: impl Into<Image<'a>>) -> Self {
148 self.header_icon = Some(icon.into());
149 self
150 }
151
152 pub fn frame(mut self, frame: Frame) -> Self {
156 self.frame = Some(frame);
157 self
158 }
159
160 pub fn center_header_icon(mut self, center: bool) -> Self {
163 self.center_header_icon = center;
164 self
165 }
166
167 pub fn backdrop_order(mut self, order: Order) -> Self {
169 self.backdrop_order = order;
170 self
171 }
172
173 pub fn content_order(mut self, order: Order) -> Self {
175 self.content_order = order;
176 self
177 }
178
179 pub fn max_width(mut self, max_width: f32) -> Self {
182 self.max_width = Some(max_width);
183 self
184 }
185
186 pub fn closable(mut self, closable: bool) -> Self {
199 self.closable = closable;
200 self
201 }
202
203 pub fn close_on_backdrop(mut self, close: bool) -> Self {
205 self.close_on_backdrop = close;
206 self
207 }
208
209 pub fn close_on_escape(mut self, close: bool) -> Self {
211 self.close_on_escape = close;
212 self
213 }
214
215 pub fn alert(mut self, alert: bool) -> Self {
223 self.alert = alert;
224 self
225 }
226
227 pub fn center_header(mut self, center: bool) -> Self {
231 self.center_header = center;
232 self
233 }
234
235 pub fn header_separator(mut self, show: bool) -> Self {
237 self.header_separator = show;
238 self
239 }
240
241 pub fn footer_separator(mut self, show: bool) -> Self {
243 self.footer_separator = show;
244 self
245 }
246
247 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 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 pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
271 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 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 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 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 area = area.default_width(self.max_width.unwrap_or(0.0));
337 let result = area.show(ctx, |ui| {
338 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 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 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 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 if let Some(footer) = self.footer {
470 if self.footer_separator {
471 ui.separator();
472 }
473 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 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 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 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 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#[derive(Clone, Copy, Default, Debug)]
599struct ModalFocusState {
600 was_open: bool,
603 prev_focus: Option<Id>,
606}
607
608const HEADER_CLOSE_SLOT: f32 = 48.0;
611
612fn 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}