1use crate::{
33 color::ColorRole,
34 icons::{Icon, IconName},
35 motion::{self, MotionToken},
36 theme::color,
37 tokens::{
38 elevation, state,
39 typography::{TypeRole, rem},
40 },
41};
42use gpui::{prelude::*, *};
43use std::rc::Rc;
44
45pub const DIALOG_WIDTH: f32 = 312.;
47const DIALOG_MIN_WIDTH: f32 = 280.;
48const DIALOG_MAX_WIDTH: f32 = 560.;
49const DIALOG_LARGE_WIDTH: f32 = 720.;
51const DIALOG_MARGIN: f32 = 24.;
53const DIALOG_RADIUS: Pixels = px(28.);
54const DIALOG_RISE: f32 = 24.;
56const DIALOG_SCALE_FROM: f32 = 0.8;
58pub const SCRIM_OPACITY: f32 = 0.32;
60
61type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
62
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
65pub enum DialogSize {
66 #[default]
69 Small,
70 Medium,
72 Large,
74 FullWidth,
76 FullScreen,
78}
79
80impl DialogSize {
81 pub const ALL: [Self; 5] = [
82 Self::Small,
83 Self::Medium,
84 Self::Large,
85 Self::FullWidth,
86 Self::FullScreen,
87 ];
88}
89
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum DialogAnimation {
94 Scale,
98 SlideUp,
100 #[default]
102 SlideDown,
103 Fade,
105}
106
107impl DialogAnimation {
108 pub const ALL: [Self; 4] = [Self::Scale, Self::SlideUp, Self::SlideDown, Self::Fade];
109}
110
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113pub enum DialogActionAlignment {
114 #[default]
116 End,
117 Start,
118 Center,
119 SpaceBetween,
121}
122
123impl DialogActionAlignment {
124 pub const ALL: [Self; 4] = [Self::End, Self::Start, Self::Center, Self::SpaceBetween];
125}
126
127#[derive(gpui::IntoElement)]
130pub struct MaterialDialog {
131 id: ElementId,
132 icon: Option<Icon>,
133 headline: Option<SharedString>,
134 subtitle: Option<SharedString>,
135 label: Option<SharedString>,
136 supporting: Option<SharedString>,
137 content: Vec<AnyElement>,
138 actions: Vec<AnyElement>,
139 divider: bool,
140 dividers: bool,
141 width: f32,
142 size: DialogSize,
143 animation: DialogAnimation,
144 alignment: DialogActionAlignment,
145 close_label: Option<SharedString>,
146 full_screen: Option<FullScreen>,
147 modal: Option<bool>,
148 contained: bool,
149 dismiss_on_scrim: bool,
150 dismiss_on_escape: bool,
151 on_dismiss: Option<Dismiss>,
152}
153
154struct FullScreen {
157 close_label: Option<SharedString>,
158 confirm: Option<AnyElement>,
159}
160
161impl MaterialDialog {
162 pub fn new(id: impl Into<ElementId>) -> Self {
163 Self {
164 id: id.into(),
165 icon: None,
166 headline: None,
167 subtitle: None,
168 label: None,
169 supporting: None,
170 content: Vec::new(),
171 actions: Vec::new(),
172 divider: false,
173 dividers: false,
174 width: DIALOG_WIDTH,
175 size: DialogSize::Small,
176 animation: DialogAnimation::default(),
177 alignment: DialogActionAlignment::End,
178 close_label: None,
179 full_screen: None,
180 modal: None,
181 contained: false,
182 dismiss_on_scrim: true,
183 dismiss_on_escape: true,
184 on_dismiss: None,
185 }
186 }
187 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
189 self.icon = Some(icon.into());
190 self
191 }
192 pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
193 self.headline = Some(headline.into());
194 self
195 }
196 pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
198 self.subtitle = Some(subtitle.into());
199 self
200 }
201 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
204 self.label = Some(label.into());
205 self
206 }
207 pub fn supporting_text(mut self, text: impl Into<SharedString>) -> Self {
208 self.supporting = Some(text.into());
209 self
210 }
211 pub fn content(mut self, content: impl IntoElement) -> Self {
213 self.content.push(content.into_any_element());
214 self
215 }
216 pub fn action(mut self, action: impl IntoElement) -> Self {
218 self.actions.push(action.into_any_element());
219 self
220 }
221 pub fn divider(mut self, divider: bool) -> Self {
223 self.divider = divider;
224 self
225 }
226 pub fn dividers(mut self, dividers: bool) -> Self {
229 self.dividers = dividers;
230 self
231 }
232 pub fn width(mut self, width: f32) -> Self {
234 self.width = width.clamp(DIALOG_MIN_WIDTH, DIALOG_MAX_WIDTH);
235 self
236 }
237 pub fn size(mut self, size: DialogSize) -> Self {
239 self.size = size;
240 self
241 }
242 pub fn animation(mut self, animation: DialogAnimation) -> Self {
243 self.animation = animation;
244 self
245 }
246 pub fn action_alignment(mut self, alignment: DialogActionAlignment) -> Self {
247 self.alignment = alignment;
248 self
249 }
250 pub fn close_button(mut self, label: impl Into<SharedString>) -> Self {
253 self.close_label = Some(label.into());
254 self
255 }
256 pub fn full_screen(
259 mut self,
260 close_label: impl Into<SharedString>,
261 confirm: Option<AnyElement>,
262 ) -> Self {
263 self.full_screen = Some(FullScreen {
264 close_label: Some(close_label.into()),
265 confirm,
266 });
267 self
268 }
269 pub fn modal(mut self, open: bool) -> Self {
271 self.modal = Some(open);
272 self
273 }
274 pub fn contained(mut self, contained: bool) -> Self {
277 self.contained = contained;
278 self
279 }
280 pub fn dismiss_on_scrim(mut self, dismiss: bool) -> Self {
282 self.dismiss_on_scrim = dismiss;
283 self
284 }
285 pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
287 self.dismiss_on_escape = dismiss;
288 self
289 }
290 pub fn on_dismiss(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
293 self.on_dismiss = Some(Rc::new(f));
294 self
295 }
296
297 fn basic_width(&self, available: f32) -> f32 {
300 let width = match self.size {
301 DialogSize::Small | DialogSize::FullScreen => self.width,
302 DialogSize::Medium => DIALOG_MAX_WIDTH,
303 DialogSize::Large => DIALOG_LARGE_WIDTH,
304 DialogSize::FullWidth if available.is_finite() => available - 2. * DIALOG_MARGIN,
306 DialogSize::FullWidth => DIALOG_MAX_WIDTH,
307 };
308 width.min(available - 2. * DIALOG_MARGIN).max(0.)
309 }
310}
311
312pub(crate) fn scale_clip(size: Size<Pixels>, spatial: f32) -> (Size<Pixels>, Point<Pixels>) {
315 let scale = DIALOG_SCALE_FROM + (1. - DIALOG_SCALE_FROM) * spatial;
316 let clip = size.map(|length| length * scale);
317 let offset = point(
318 (clip.width - size.width) / 2.,
319 (clip.height - size.height) / 2.,
320 );
321 (clip, offset)
322}
323
324fn slide_offset(animation: DialogAnimation, spatial: f32) -> f32 {
326 match animation {
327 DialogAnimation::SlideUp => DIALOG_RISE * (1. - spatial),
328 DialogAnimation::SlideDown => -DIALOG_RISE * (1. - spatial),
329 DialogAnimation::Scale | DialogAnimation::Fade => 0.,
330 }
331}
332fn typed<E: Styled>(element: E, role: TypeRole) -> E {
333 let scale = role.scale();
334 element
335 .font_family(scale.font_family)
336 .text_size(scale.font_size)
337 .line_height(scale.line_height)
338 .font_weight(FontWeight(scale.font_weight as f32))
339}
340
341#[derive(Clone, Copy, Debug, PartialEq)]
344pub(crate) struct OverlayFrame {
345 pub spatial: f32,
346 pub fade: f32,
347}
348
349impl OverlayFrame {
350 pub(crate) fn settled_closed(self, open: bool) -> bool {
352 !open && self.spatial == 0. && self.fade <= 0.
353 }
354 pub(crate) fn opacity(self) -> f32 {
355 self.fade.clamp(0., 1.)
356 }
357}
358
359pub(crate) fn overlay_frame(
361 id: &ElementId,
362 open: bool,
363 window: &mut Window,
364 cx: &mut App,
365) -> OverlayFrame {
366 let target = if open { 1. } else { 0. };
367 OverlayFrame {
368 spatial: motion::token_value(
369 (id.clone(), "overlay-spatial"),
370 target,
371 MotionToken::DefaultSpatial,
372 window,
373 cx,
374 ),
375 fade: motion::token_value(
376 (id.clone(), "overlay-fade"),
377 target,
378 MotionToken::DefaultEffects,
379 window,
380 cx,
381 ),
382 }
383}
384
385pub(crate) struct ModalFocus {
389 pub focus: FocusHandle,
390 previous: Option<FocusHandle>,
391 shown: bool,
392}
393
394pub(crate) fn modal_focus(
397 id: &ElementId,
398 open: bool,
399 window: &mut Window,
400 cx: &mut App,
401) -> FocusHandle {
402 let state = window.use_keyed_state((id.clone(), "modal-focus"), cx, |_, cx| ModalFocus {
403 focus: cx.focus_handle(),
404 previous: None,
405 shown: false,
406 });
407 let shown = state.read(cx).shown;
408 if open && !shown {
409 let previous = window.focused(cx);
410 state.update(cx, |state, _| {
411 state.shown = true;
412 state.previous = previous;
413 });
414 let target = state.read(cx).focus.clone();
415 window.defer(cx, move |window, cx| target.focus(window, cx));
417 } else if !open && shown {
418 let previous = state.update(cx, |state, _| {
419 state.shown = false;
420 state.previous.take()
421 });
422 if let Some(previous) = previous {
423 window.defer(cx, move |window, cx| previous.focus(window, cx));
424 }
425 }
426 state.read(cx).focus.clone()
427}
428
429pub(crate) fn scrim(fade: f32) -> Div {
431 div()
434 .absolute()
435 .inset_0()
436 .occlude()
437 .bg(color(ColorRole::SCRIM).opacity(SCRIM_OPACITY * fade.clamp(0., 1.)))
438}
439
440pub(crate) fn header_icon_button(
442 id: impl Into<ElementId>,
443 icon: Icon,
444 label: SharedString,
445 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
446) -> impl IntoElement {
447 let ink = color(ColorRole::ON_SURFACE_VARIANT);
448 let clear = Rgba { a: 0., ..ink };
449 let id = id.into();
450 div()
451 .debug_selector({
452 let selector = id.clone();
453 move || selector.to_string()
454 })
455 .size(rem(48.))
456 .flex()
457 .flex_shrink_0()
458 .items_center()
459 .justify_center()
460 .child(super::FocusRing::new(
461 ElementId::NamedChild(std::sync::Arc::new(id.clone()), "ring".into()),
462 gpui_base::Button::new(id)
463 .accessibility_label(label)
464 .size(rem(40.))
465 .rounded_full()
466 .flex()
467 .items_center()
468 .justify_center()
469 .cursor_pointer()
470 .hover(move |style| style.bg(state::hovered(clear, ink)))
471 .active(move |style| style.bg(state::pressed(clear, ink)))
472 .child(icon.size(rem(24.)).color(ink))
473 .on_click(on_click),
474 ))
475}
476
477pub(crate) struct ContainerBounds(Option<Bounds<Pixels>>);
480
481pub(crate) fn container_state(
483 id: &ElementId,
484 window: &mut Window,
485 cx: &mut App,
486) -> Entity<ContainerBounds> {
487 window.use_keyed_state((id.clone(), "overlay-container"), cx, |_, _| {
488 ContainerBounds(None)
489 })
490}
491
492pub(crate) fn overlay_area(
495 container: Option<&Entity<ContainerBounds>>,
496 window: &Window,
497 cx: &App,
498) -> Option<Bounds<Pixels>> {
499 match container {
500 Some(container) => container.read(cx).0,
501 None => Some(Bounds::new(Point::default(), window.viewport_size())),
502 }
503}
504
505pub(crate) fn place_host<E: Styled>(host: E, area: Bounds<Pixels>) -> E {
507 host.left(area.origin.x)
508 .top(area.origin.y)
509 .w(area.size.width)
510 .h(area.size.height)
511 .overflow_hidden()
513}
514
515pub(crate) fn with_container_probe(
519 container: Option<Entity<ContainerBounds>>,
520 host: AnyElement,
521) -> AnyElement {
522 let Some(container) = container else {
523 return host;
524 };
525 div()
526 .absolute()
527 .inset_0()
528 .child(
529 canvas(
530 move |bounds, window, cx| {
531 if container.read(cx).0 != Some(bounds) {
532 container.update(cx, |container, _| container.0 = Some(bounds));
533 window.request_animation_frame();
534 }
535 },
536 |_, _, _, _| {},
537 )
538 .absolute()
539 .inset_0(),
540 )
541 .child(host)
542 .into_any_element()
543}
544
545struct MeasuredSize(Option<Size<Pixels>>);
547
548fn divider_line() -> Div {
549 div()
550 .h(px(1.))
551 .w_full()
552 .flex_shrink_0()
553 .bg(color(ColorRole::OUTLINE_VARIANT))
554}
555
556impl MaterialDialog {
557 fn actions_row(alignment: DialogActionAlignment, actions: Vec<AnyElement>) -> Option<Div> {
559 if actions.is_empty() {
560 return None;
561 }
562 let row = div()
563 .flex()
564 .flex_shrink_0()
565 .items_center()
566 .gap(rem(8.))
567 .pt(rem(20.))
568 .pb(rem(20.));
569 let row = match alignment {
570 DialogActionAlignment::End => row.justify_end().pl(rem(8.)).pr(rem(24.)),
571 DialogActionAlignment::Start => row.justify_start().pl(rem(24.)).pr(rem(8.)),
572 DialogActionAlignment::Center => row.justify_center().px(rem(24.)),
573 DialogActionAlignment::SpaceBetween => row.justify_between().px(rem(24.)),
574 };
575 Some(row.children(actions))
576 }
577
578 fn supporting_element(supporting: SharedString) -> Div {
579 typed(div(), TypeRole::BodyMedium)
580 .w_full()
581 .text_color(color(ColorRole::ON_SURFACE_VARIANT))
582 .child(supporting)
583 }
584
585 fn basic_surface(
586 self,
587 width: f32,
588 dismiss: Option<Dismiss>,
589 measure: Option<Entity<MeasuredSize>>,
590 ) -> Stateful<Div> {
591 let centered = self.icon.is_some();
592 let dividers = self.dividers;
593 let has_close = self.close_label.is_some();
594 let supporting = self.supporting;
595 let text = div()
596 .flex()
597 .flex_col()
598 .gap(rem(16.))
599 .pt(rem(24.))
600 .px(rem(24.))
601 .when(centered, |text| text.items_center())
602 .when_some(self.icon, |text, icon| {
603 text.child(icon.size(rem(24.)).color(color(ColorRole::SECONDARY)))
604 })
605 .when_some(self.headline, |text, headline| {
606 text.child(
607 typed(div(), TypeRole::HeadlineSmall)
608 .w_full()
609 .when(centered, |headline| headline.text_center())
610 .when(has_close && !centered, |headline| headline.pr(rem(32.)))
611 .text_color(color(ColorRole::ON_SURFACE))
612 .child(headline),
613 )
614 })
615 .when_some(self.subtitle, |text, subtitle| {
616 text.child(Self::supporting_element(subtitle).when(centered, |s| s.text_center()))
617 })
618 .when(!dividers, |text| {
619 text.when_some(supporting.clone(), |text, supporting| {
620 text.child(Self::supporting_element(supporting))
621 })
622 });
623 let content = self.content;
624 let has_content = !content.is_empty();
625 let middle = if dividers {
628 Some(
629 div()
630 .flex()
631 .flex_col()
632 .gap(rem(16.))
633 .py(rem(16.))
634 .when_some(supporting, |body, supporting| {
635 body.child(
636 div()
637 .px(rem(24.))
638 .child(Self::supporting_element(supporting)),
639 )
640 })
641 .children(content),
642 )
643 } else {
644 has_content.then(|| div().pt(rem(16.)).flex().flex_col().children(content))
645 };
646 let actions = Self::actions_row(self.alignment, self.actions);
647 let has_actions = actions.is_some();
648 let close = self.close_label.map(|label| {
649 div()
650 .absolute()
651 .top(rem(12.))
652 .right(rem(12.))
653 .child(header_icon_button(
654 "dialog-close",
655 IconName::Close.icon(),
656 label,
657 move |_, window, cx| {
658 if let Some(dismiss) = &dismiss {
659 dismiss(window, cx);
660 }
661 },
662 ))
663 });
664 let mut surface = div()
665 .id(self.id)
666 .relative()
667 .flex()
668 .flex_col()
669 .flex_shrink_0()
670 .w(rem(width))
671 .overflow_hidden()
672 .rounded(DIALOG_RADIUS)
673 .bg(color(ColorRole::SURFACE_CONTAINER_HIGH))
674 .shadow(elevation::level3())
675 .child(text);
676 match middle {
677 Some(body) if dividers => {
678 surface = surface
679 .child(div().pt(rem(16.)).child(divider_line()))
680 .child(body)
681 .child(divider_line());
682 }
683 Some(content) => surface = surface.child(content),
684 None => {}
685 }
686 surface
687 .when(self.divider && !dividers, |surface| {
688 surface.child(div().pt(rem(16.)).child(divider_line()))
689 })
690 .children(actions)
691 .when(!has_actions, |surface| surface.child(div().h(rem(24.))))
692 .children(close)
693 .when_some(measure, |surface, measure| {
694 surface.child(
695 canvas(
696 move |bounds, window, cx| {
697 if measure.read(cx).0 != Some(bounds.size) {
698 measure.update(cx, |measure, _| measure.0 = Some(bounds.size));
699 window.request_animation_frame();
700 }
701 },
702 |_, _, _, _| {},
703 )
704 .absolute()
705 .inset_0(),
706 )
707 })
708 }
709}
710
711impl MaterialDialog {
712 fn full_screen_surface(mut self, dismiss: Option<Dismiss>) -> Stateful<Div> {
713 let full = self.full_screen.take().unwrap_or(FullScreen {
714 close_label: None,
715 confirm: None,
716 });
717 let close_label = full.close_label.or(self.close_label.take());
718 let dividers = self.dividers;
719 let footer = Self::actions_row(self.alignment, std::mem::take(&mut self.actions));
720 div()
721 .id(self.id)
722 .flex()
723 .flex_col()
724 .size_full()
725 .bg(color(ColorRole::SURFACE))
726 .child(
727 div()
728 .flex()
729 .flex_shrink_0()
730 .items_center()
731 .h(rem(56.))
732 .pl(rem(if close_label.is_some() { 4. } else { 24. }))
733 .pr(rem(12.))
734 .gap(rem(4.))
735 .when_some(close_label, |header, label| {
736 header.child(header_icon_button(
737 "dialog-close",
738 IconName::Close.icon(),
739 label,
740 move |_, window, cx| {
741 if let Some(dismiss) = &dismiss {
742 dismiss(window, cx);
743 }
744 },
745 ))
746 })
747 .child(
748 typed(div(), TypeRole::TitleLarge)
749 .flex_1()
750 .min_w_0()
751 .truncate()
752 .text_color(color(ColorRole::ON_SURFACE))
753 .children(self.headline),
754 )
755 .children(full.confirm),
756 )
757 .when(dividers, |page| page.child(divider_line()))
758 .child(
759 div()
760 .id("dialog-body")
761 .flex()
762 .flex_col()
763 .flex_1()
764 .min_h_0()
765 .overflow_y_scroll()
766 .gap(rem(16.))
767 .px(rem(24.))
768 .when(dividers, |body| body.py(rem(16.)))
769 .when_some(self.subtitle, |body, subtitle| {
770 body.child(Self::supporting_element(subtitle))
771 })
772 .when_some(self.supporting, |body, supporting| {
773 body.child(Self::supporting_element(supporting))
774 })
775 .children(self.content),
776 )
777 .when(dividers && footer.is_some(), |page| {
778 page.child(divider_line())
779 })
780 .children(footer)
781 }
782}
783
784impl RenderOnce for MaterialDialog {
785 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
786 let full_screen = self.full_screen.is_some() || self.size == DialogSize::FullScreen;
787 let Some(open) = self.modal else {
788 return if full_screen {
790 self.full_screen_surface(None).into_any_element()
791 } else {
792 let width = self.basic_width(f32::INFINITY);
793 let dismiss = self.on_dismiss.clone();
794 self.basic_surface(width, dismiss, None).into_any_element()
795 };
796 };
797 let id = self.id.clone();
798 let container = self.contained.then(|| container_state(&id, window, cx));
799 let frame = overlay_frame(&id, open, window, cx);
800 let focus = modal_focus(&id, open, window, cx);
801 if frame.settled_closed(open) {
802 return with_container_probe(container, div().into_any_element());
803 }
804 let Some(area) = overlay_area(container.as_ref(), window, cx) else {
805 return with_container_probe(container, div().into_any_element());
807 };
808 let dismiss = self.on_dismiss.clone();
809 let cancel_dismiss = dismiss.clone();
812 let scrim_dismiss = dismiss.clone();
813 let opacity = frame.opacity();
814 let name = self.label.clone().or_else(|| self.headline.clone());
815 let (dismiss_on_escape, dismiss_on_scrim) = (self.dismiss_on_escape, self.dismiss_on_scrim);
816 let surface = if full_screen {
817 div()
818 .absolute()
819 .left_0()
820 .w(area.size.width)
821 .h(area.size.height)
822 .top(area.size.height * (1. - frame.spatial))
823 .opacity(opacity)
824 .child(self.full_screen_surface(dismiss.filter(|_| open)))
825 .into_any_element()
826 } else {
827 let rem_size = window.rem_size();
828 let available = f32::from(area.size.width) * crate::tokens::typography::BASE_PX
829 / f32::from(rem_size);
830 let width = self.basic_width(available);
831 let animation = self.animation;
832 let scaling = animation == DialogAnimation::Scale && frame.spatial < 1.;
833 let measure = (animation == DialogAnimation::Scale).then(|| {
834 window.use_keyed_state((id.clone(), "dialog-size"), cx, |_, _| MeasuredSize(None))
835 });
836 let measured = measure.as_ref().and_then(|measure| measure.read(cx).0);
837 let basic = self.basic_surface(width, dismiss.filter(|_| open), measure);
838 let positioned = div()
839 .relative()
840 .top(rem(slide_offset(animation, frame.spatial)))
841 .opacity(opacity);
842 match measured.filter(|_| scaling) {
843 Some(size) => {
844 let (clip, offset) = scale_clip(size, frame.spatial.max(0.));
845 positioned
846 .child(
847 div()
848 .relative()
849 .w(clip.width)
850 .h(clip.height)
851 .overflow_hidden()
852 .rounded(DIALOG_RADIUS)
853 .child(div().absolute().left(offset.x).top(offset.y).child(basic)),
854 )
855 .into_any_element()
856 }
857 None => positioned.child(basic).into_any_element(),
858 }
859 };
860 let host = gpui_base::Dialog::new(cx)
861 .open(true)
862 .when_some(name, |dialog, name| dialog.accessibility_label(name))
863 .focus_handle(focus)
864 .close_on_escape(dismiss_on_escape)
865 .close_on_backdrop_press(false)
868 .on_ok(|_, _, _| false)
870 .on_cancel(move |_, window, cx| {
871 if open && let Some(dismiss) = &cancel_dismiss {
872 dismiss(window, cx);
873 }
874 false
875 })
876 .backdrop(
877 scrim(frame.fade).on_mouse_down(MouseButton::Left, move |_, window, cx| {
878 cx.stop_propagation();
879 if open
880 && dismiss_on_scrim
881 && let Some(dismiss) = &scrim_dismiss
882 {
883 dismiss(window, cx);
884 }
885 }),
886 )
887 .popup(surface);
888 let host = if container.is_some() {
889 place_host(host, area)
890 } else {
891 host
892 };
893 with_container_probe(container, host.into_any_element())
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use core::prelude::v1::test;
901 use gpui::{Context, Modifiers, TestAppContext, VisualTestContext};
902 use std::cell::Cell;
903
904 #[test]
905 fn a_closing_overlay_settles_only_at_rest() {
906 let frame = |spatial, fade| OverlayFrame { spatial, fade };
907 assert!(!frame(0., 0.).settled_closed(true), "opening starts at 0");
908 assert!(!frame(0.3, 0.2).settled_closed(false));
909 assert!(!frame(-0.01, 0.).settled_closed(false), "overshoot moves");
910 assert!(frame(0., 0.).settled_closed(false));
911 }
912
913 #[test]
914 fn width_stays_within_m3_bounds() {
915 assert_eq!(MaterialDialog::new("d").width(100.).width, 280.);
916 assert_eq!(MaterialDialog::new("d").width(900.).width, 560.);
917 assert_eq!(MaterialDialog::new("d").width, DIALOG_WIDTH);
918 }
919
920 struct Harness {
921 open: bool,
922 dismissals: Rc<Cell<usize>>,
923 actions: Rc<Cell<usize>>,
924 trigger: FocusHandle,
925 action: FocusHandle,
926 }
927 impl Render for Harness {
928 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
929 window.set_rem_size(px(16.));
930 let dismissals = self.dismissals.clone();
931 let actions = self.actions.clone();
932 div()
933 .size_full()
934 .child(div().id("trigger").track_focus(&self.trigger).size(px(20.)))
935 .child(
936 MaterialDialog::new("dialog")
937 .headline("Title")
938 .supporting_text("Body")
939 .action(
940 gpui_base::Button::new("action")
941 .track_focus(&self.action)
942 .debug_selector(|| "dialog-action".into())
943 .size(px(40.))
944 .on_click(move |_, _, _| actions.set(actions.get() + 1)),
945 )
946 .modal(self.open)
947 .on_dismiss({
948 let this = cx.entity().downgrade();
949 move |_, cx| {
950 dismissals.set(dismissals.get() + 1);
951 let _ = this.update(cx, |this, cx| {
952 this.open = false;
953 cx.notify();
954 });
955 }
956 }),
957 )
958 }
959 }
960
961 fn draw(cx: &mut VisualTestContext) {
962 cx.update(|window, cx| {
963 window.refresh();
964 window.draw(cx).clear(cx);
965 });
966 cx.run_until_parked();
967 }
968
969 fn key(cx: &mut VisualTestContext, key: &str) {
970 let keystroke =
971 cx.update(|_, cx| gpui::Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
972 cx.simulate_event(gpui::KeyDownEvent {
973 keystroke: keystroke.clone(),
974 is_held: false,
975 prefer_character_input: false,
976 });
977 cx.simulate_event(gpui::KeyUpEvent { keystroke });
978 cx.run_until_parked();
979 }
980
981 #[gpui::test]
982 fn focus_moves_in_traps_and_returns_and_escape_dismisses(cx: &mut TestAppContext) {
983 cx.update(|cx| {
984 gpui_base::init(cx);
985 cx.set_reduce_motion(true);
986 });
987 let dismissals = Rc::new(Cell::new(0));
988 let actions = Rc::new(Cell::new(0));
989 let (view, cx) = cx.add_window_view({
990 let (dismissals, actions) = (dismissals.clone(), actions.clone());
991 move |_, cx| Harness {
992 open: false,
993 dismissals,
994 actions,
995 trigger: cx.focus_handle(),
996 action: cx.focus_handle(),
997 }
998 });
999 draw(cx);
1000 cx.update(|window, cx| view.read(cx).trigger.clone().focus(window, cx));
1001 view.update(cx, |v, cx| {
1002 v.open = true;
1003 cx.notify();
1004 });
1005 draw(cx);
1006 draw(cx);
1007 assert!(cx.debug_bounds("dialog-action").is_some());
1008 cx.update(|window, cx| {
1009 assert!(
1010 !view.read(cx).trigger.is_focused(window),
1011 "focus moved into the dialog"
1012 );
1013 });
1014 for _ in 0..3 {
1016 cx.update(|window, cx| window.focus_next(cx));
1017 }
1018 draw(cx);
1019 cx.update(|window, cx| assert!(view.read(cx).action.is_focused(window)));
1020 key(cx, "enter");
1022 draw(cx);
1023 assert_eq!(dismissals.get(), 0);
1024 key(cx, "escape");
1025 draw(cx);
1026 assert_eq!(dismissals.get(), 1);
1027 draw(cx);
1028 draw(cx);
1029 assert!(cx.debug_bounds("dialog-action").is_none(), "settled closed");
1030 cx.update(|window, cx| {
1031 assert!(
1032 view.read(cx).trigger.is_focused(window),
1033 "focus returned to the trigger"
1034 );
1035 });
1036 let _ = actions.get();
1037 let _ = Modifiers::default();
1038 }
1039
1040 #[test]
1041 fn sizes_pick_their_widths_within_the_container() {
1042 let width = |size| MaterialDialog::new("d").size(size).basic_width(1000.);
1043 assert_eq!(width(DialogSize::Small), DIALOG_WIDTH);
1044 assert_eq!(width(DialogSize::Medium), 560.);
1045 assert_eq!(width(DialogSize::Large), 720.);
1046 assert_eq!(width(DialogSize::FullWidth), 952.);
1047 let narrow = MaterialDialog::new("d").size(DialogSize::Large);
1049 assert_eq!(narrow.basic_width(400.), 352.);
1050 let specimen = MaterialDialog::new("d").size(DialogSize::FullWidth);
1052 assert_eq!(specimen.basic_width(f32::INFINITY), 560.);
1053 }
1054
1055 #[test]
1056 fn animations_move_scale_or_only_fade() {
1057 assert_eq!(slide_offset(DialogAnimation::SlideUp, 0.), 24.);
1058 assert_eq!(slide_offset(DialogAnimation::SlideDown, 0.), -24.);
1059 assert_eq!(slide_offset(DialogAnimation::SlideUp, 1.), 0.);
1060 assert_eq!(slide_offset(DialogAnimation::Fade, 0.), 0.);
1061 assert_eq!(slide_offset(DialogAnimation::Scale, 0.), 0.);
1062 let size = gpui::size(px(300.), px(200.));
1063 let (clip, offset) = scale_clip(size, 0.);
1064 assert_eq!(clip, gpui::size(px(240.), px(160.)));
1065 assert_eq!(offset, point(px(-30.), px(-20.)), "the clip stays centered");
1066 assert_eq!(scale_clip(size, 1.), (size, point(px(0.), px(0.))));
1067 }
1068
1069 struct Contained {
1070 open: bool,
1071 scrim: bool,
1072 dismissals: usize,
1073 }
1074 impl Render for Contained {
1075 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1076 window.set_rem_size(px(16.));
1077 let this = cx.entity().downgrade();
1078 div().size_full().pl(px(200.)).child(
1079 div()
1080 .relative()
1081 .debug_selector(|| "frame".into())
1082 .w(px(500.))
1083 .h(px(400.))
1084 .child(
1085 MaterialDialog::new("contained")
1086 .headline("Title")
1087 .action(
1088 div()
1089 .debug_selector(|| "contained-action".into())
1090 .size(px(40.)),
1091 )
1092 .size(DialogSize::FullWidth)
1093 .animation(DialogAnimation::Scale)
1094 .close_button("Close")
1095 .dismiss_on_scrim(self.scrim)
1096 .contained(true)
1097 .modal(self.open)
1098 .on_dismiss(move |_, cx| {
1099 let _ = this.update(cx, |this, cx| {
1100 this.dismissals += 1;
1101 this.open = false;
1102 cx.notify();
1103 });
1104 }),
1105 ),
1106 )
1107 }
1108 }
1109
1110 #[gpui::test]
1111 fn a_contained_dialog_presents_over_its_parent_and_dismisses(cx: &mut TestAppContext) {
1112 cx.update(|cx| {
1113 gpui_base::init(cx);
1114 cx.set_reduce_motion(true);
1115 });
1116 let (view, cx) = cx.add_window_view(|_, _| Contained {
1117 open: true,
1118 scrim: false,
1119 dismissals: 0,
1120 });
1121 for _ in 0..3 {
1122 draw(cx);
1123 }
1124 let frame = cx.debug_bounds("frame").unwrap();
1125 let action = cx.debug_bounds("contained-action").unwrap();
1126 assert!(frame.contains(&action.origin), "{action:?} in {frame:?}");
1127 let close = cx.debug_bounds("dialog-close").unwrap();
1129 assert!(close.right() <= frame.right() - px(24.));
1130 assert!(close.right() > frame.right() - px(48.));
1131 let corner = frame.origin + point(px(4.), px(4.));
1133 cx.simulate_click(corner, Modifiers::default());
1134 draw(cx);
1135 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0);
1136 view.update(cx, |v, cx| {
1137 v.scrim = true;
1138 cx.notify();
1139 });
1140 draw(cx);
1141 cx.simulate_click(corner, Modifiers::default());
1142 draw(cx);
1143 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 1);
1144 view.update(cx, |v, cx| {
1146 v.open = true;
1147 cx.notify();
1148 });
1149 for _ in 0..3 {
1150 draw(cx);
1151 }
1152 let close = cx.debug_bounds("dialog-close").unwrap();
1153 cx.simulate_click(close.center(), Modifiers::default());
1154 draw(cx);
1155 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 2);
1156 }
1157}