Skip to main content

gpui_kit_material/components/
dialog.rs

1//! M3 dialogs: basic (icon, headline, supporting text, optional content such
2//! as a list, actions) and full-screen.
3//!
4//! Visuals follow the Figma "Material 3 Design Kit" `Basic dialog` /
5//! `List dialog` and m3e.kyralabs.dev: a surface-container-high container
6//! with 28dp corners at level 3, 24dp padding and 16dp gaps, a 24dp
7//! secondary icon above a headline-small headline (centered when the icon is
8//! present), body-medium on-surface-variant supporting text, and trailing
9//! text actions 8dp apart. A full-screen dialog is a surface page with a
10//! close button, title-large headline and a confirming action.
11//!
12//! md3.io's dialog options are available too: a subtitle (body-medium
13//! on-surface-variant, 16dp under the headline), [`DialogSize`]s, a close
14//! button in the header, dividers above and below a scrolling body, the
15//! actions' [`DialogActionAlignment`], the [`DialogAnimation`], and whether
16//! the scrim and Escape dismiss.
17//!
18//! Behavior is GPUI Base's modal [`gpui_base::Dialog`] host: the dialog role,
19//! the focus trap (Tab cycles inside), Escape and scrim dismissal. Material
20//! adds what the host leaves to callers: focus moves into the dialog when it
21//! opens and returns to the previously focused element when it closes, and
22//! the open/close motion. By default the container slides into place from
23//! 24dp above on the default spatial spring while it and the 32% scrim fade
24//! on the default effects spring; a full-screen dialog slides up from the
25//! bottom edge. Reduced motion finishes the movement immediately and keeps
26//! the fades. Closing plays the same channels in reverse and keeps the host
27//! (and its focus trap) until they settle; the content then leaves the tree.
28//!
29//! A [`MaterialDialog::contained`] dialog presents over its positioned parent
30//! instead of the whole window (e.g. a device frame in a preview): the host,
31//! scrim and full-screen surface take the parent's bounds.
32use 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
45/// The basic dialog's container width in the design kit; M3 allows 280–560dp.
46pub const DIALOG_WIDTH: f32 = 312.;
47const DIALOG_MIN_WIDTH: f32 = 280.;
48const DIALOG_MAX_WIDTH: f32 = 560.;
49/// md3.io's large dialog width, for large windows.
50const DIALOG_LARGE_WIDTH: f32 = 720.;
51/// The margin a dialog keeps from its container's edges.
52const DIALOG_MARGIN: f32 = 24.;
53const DIALOG_RADIUS: Pixels = px(28.);
54/// How far a basic dialog slides while it opens, in dp.
55const DIALOG_RISE: f32 = 24.;
56/// The scale a scaling dialog opens from.
57const DIALOG_SCALE_FROM: f32 = 0.8;
58/// The scrim's opacity over the content behind a modal surface.
59pub const SCRIM_OPACITY: f32 = 0.32;
60
61type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
62
63/// A dialog's size (md3.io): the basic widths, or a full-screen page.
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
65pub enum DialogSize {
66    /// The design kit's basic dialog ([`MaterialDialog::width`], 312dp by
67    /// default).
68    #[default]
69    Small,
70    /// M3's widest basic dialog, 560dp.
71    Medium,
72    /// 720dp, for large windows.
73    Large,
74    /// The container's width less 24dp margins.
75    FullWidth,
76    /// A full-screen dialog: a header with the close button and headline.
77    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/// How a dialog enters and leaves (md3.io). Every animation fades; reduced
91/// motion keeps only the fade.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum DialogAnimation {
94    /// Grows from 80% around its center. GPUI has no element scale
95    /// transform, so the container's clip grows while its content stays at
96    /// full size.
97    Scale,
98    /// Rises 24dp into place.
99    SlideUp,
100    /// Descends 24dp into place.
101    #[default]
102    SlideDown,
103    /// Fades only.
104    Fade,
105}
106
107impl DialogAnimation {
108    pub const ALL: [Self; 4] = [Self::Scale, Self::SlideUp, Self::SlideDown, Self::Fade];
109}
110
111/// Where a dialog's actions sit in their row (md3.io).
112#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113pub enum DialogActionAlignment {
114    /// The trailing edge (M3's default).
115    #[default]
116    End,
117    Start,
118    Center,
119    /// The first action at the start, the last at the end.
120    SpaceBetween,
121}
122
123impl DialogActionAlignment {
124    pub const ALL: [Self; 4] = [Self::End, Self::Start, Self::Center, Self::SpaceBetween];
125}
126
127/// An M3 dialog. Without [`MaterialDialog::modal`] it renders its container
128/// in place (a specimen); with it, it is a modal overlay over the window.
129#[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
154/// The full-screen dialog's header: its close button's accessible name and
155/// the confirming action.
156struct 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    /// The hero icon above the headline; the headline then centers.
188    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    /// A body-medium line 16dp under the headline (md3.io).
197    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
198        self.subtitle = Some(subtitle.into());
199        self
200    }
201    /// The modal dialog's accessible name when it differs from (or there is
202    /// no) headline.
203    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    /// Content under the text, full width (e.g. a list of items).
212    pub fn content(mut self, content: impl IntoElement) -> Self {
213        self.content.push(content.into_any_element());
214        self
215    }
216    /// An action button; actions sit at the trailing edge in call order.
217    pub fn action(mut self, action: impl IntoElement) -> Self {
218        self.actions.push(action.into_any_element());
219        self
220    }
221    /// An outline-variant divider between the content and the actions.
222    pub fn divider(mut self, divider: bool) -> Self {
223        self.divider = divider;
224        self
225    }
226    /// Outline-variant dividers above and below the body (supporting text
227    /// and content), for a body that scrolls (md3.io's Dividers).
228    pub fn dividers(mut self, dividers: bool) -> Self {
229        self.dividers = dividers;
230        self
231    }
232    /// The basic dialog's width in dp, clamped to M3's 280–560dp.
233    pub fn width(mut self, width: f32) -> Self {
234        self.width = width.clamp(DIALOG_MIN_WIDTH, DIALOG_MAX_WIDTH);
235        self
236    }
237    /// The dialog's size; [`DialogSize::Small`] uses [`Self::width`].
238    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    /// A close button named `label` at the header's trailing edge; it calls
251    /// `on_dismiss`. A full-screen dialog shows it at its leading edge.
252    pub fn close_button(mut self, label: impl Into<SharedString>) -> Self {
253        self.close_label = Some(label.into());
254        self
255    }
256    /// Make this a full-screen dialog. `close_label` names its close button;
257    /// `confirm` is the header's action (e.g. a text button).
258    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    /// Present the dialog modally over the window while `open`.
270    pub fn modal(mut self, open: bool) -> Self {
271        self.modal = Some(open);
272        self
273    }
274    /// Present the modal dialog over its positioned parent's bounds instead
275    /// of the whole window.
276    pub fn contained(mut self, contained: bool) -> Self {
277        self.contained = contained;
278        self
279    }
280    /// Whether pressing the scrim dismisses the dialog (default true).
281    pub fn dismiss_on_scrim(mut self, dismiss: bool) -> Self {
282        self.dismiss_on_scrim = dismiss;
283        self
284    }
285    /// Whether Escape dismisses the dialog (default true).
286    pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
287        self.dismiss_on_escape = dismiss;
288        self
289    }
290    /// Called when the person dismisses the dialog: Escape, the scrim, or a
291    /// close button. The caller closes it.
292    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    /// The basic container's width in dp inside a container `available` dp
298    /// wide.
299    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            // A specimen has no container: the widest basic dialog.
305            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
312/// The clip a scaling dialog paints at spatial progress `spatial`, for a
313/// container `size`: the clip's size and the container's offset inside it.
314pub(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
324/// The vertical offset in dp of a sliding dialog at `spatial`.
325fn 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/// The open/close presentation of a modal surface at spatial progress
342/// `spatial` and fade `fade`, both 0 (closed) to 1 (open).
343#[derive(Clone, Copy, Debug, PartialEq)]
344pub(crate) struct OverlayFrame {
345    pub spatial: f32,
346    pub fade: f32,
347}
348
349impl OverlayFrame {
350    /// Whether a closing overlay has settled and may leave the tree.
351    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
359/// Sample the open/close channels of a modal overlay keyed by `id`.
360pub(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
385/// Focus that moves into a modal surface when it opens and returns to the
386/// previously focused element when it closes. Base's host traps focus but
387/// leaves both moves to the application.
388pub(crate) struct ModalFocus {
389    pub focus: FocusHandle,
390    previous: Option<FocusHandle>,
391    shown: bool,
392}
393
394/// The focus handle of the modal surface keyed by `id`, after applying the
395/// open/close focus moves for this frame.
396pub(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        // The host's focus handle is attached during this frame's paint.
416        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
429/// The modal scrim at the overlay's fade.
430pub(crate) fn scrim(fade: f32) -> Div {
431    // The scrim blocks the pointer (and wheel) from the content behind, so
432    // the page under a modal surface cannot scroll its owner away.
433    div()
434        .absolute()
435        .inset_0()
436        .occlude()
437        .bg(color(ColorRole::SCRIM).opacity(SCRIM_OPACITY * fade.clamp(0., 1.)))
438}
439
440/// A 40dp standard icon button (on-surface-variant) for overlay headers.
441pub(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
477/// The window bounds of a contained overlay's positioned parent, as
478/// measured the previous frame.
479pub(crate) struct ContainerBounds(Option<Bounds<Pixels>>);
480
481/// The measured parent bounds of the contained overlay keyed by `id`.
482pub(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
492/// Where an overlay presents: its container's bounds when contained (`None`
493/// until measured), otherwise the window.
494pub(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
505/// Lay an overlay host out over `area` instead of the window.
506pub(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        // Clip a partially shown sheet to the container.
512        .overflow_hidden()
513}
514
515/// Wrap a contained overlay's host with the probe measuring its parent; the
516/// probe asks for another frame when the bounds change (the host used the
517/// previous ones this frame).
518pub(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
545/// A basic dialog container's measured size, for the scale animation.
546struct 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    /// The actions row, aligned; `None` without actions.
558    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        // With dividers, the supporting text and content form the body
626        // between them; otherwise the content follows the text.
627        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            // A specimen: the container in place.
789            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            // The container is measured this frame; present on the next.
806            return with_container_probe(container, div().into_any_element());
807        };
808        let dismiss = self.on_dismiss.clone();
809        // Escape and the scrim reach the caller only while open: a closing
810        // dialog keeps its host until the motion settles.
811        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            // The scrim occludes the host's backdrop layer (so the pointer
866            // and wheel never reach the page behind), so it dismisses itself.
867            .close_on_backdrop_press(false)
868            // Enter activates the focused action; it never dismisses.
869            .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        // Tab stays inside: the action is the dialog's only stop.
1015        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        // Enter activates the action and does not dismiss.
1021        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        // A narrow container caps every size at its width less margins.
1048        let narrow = MaterialDialog::new("d").size(DialogSize::Large);
1049        assert_eq!(narrow.basic_width(400.), 352.);
1050        // A specimen (no container) shows the widest basic dialog.
1051        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        // Full width: the container less 24dp margins on each side.
1128        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        // The scrim does not dismiss when turned off, then does.
1132        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        // The close button dismisses too.
1145        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}