Skip to main content

gpui_component/dialog/
dialog.rs

1use crate::root::WindowState;
2use gpui_base::TestSupportExt as _;
3use std::{rc::Rc, sync::LazyLock, time::Duration};
4
5use gpui::{
6    Action, Anchor, Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Edges,
7    FocusHandle, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
8    SharedString, StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point,
9    prelude::FluentBuilder, px,
10};
11use gpui_base::{ElementExt as _, TextSelectionScopeId};
12use rust_i18n::t;
13
14use crate::{
15    ActiveTheme as _, IconName, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _,
16    animation::cubic_bezier,
17    button::{Button, ButtonVariant, ButtonVariants as _},
18    dialog::{DialogContent, DialogDispatchAnchor, DialogTitle},
19    scroll::ScrollableElement as _,
20    v_flex,
21};
22
23pub static ANIMATION_DURATION: LazyLock<Duration> = LazyLock::new(|| Duration::from_secs_f64(0.25));
24pub use gpui_base::actions::{Cancel, Confirm};
25
26type OkHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
27type CancelHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
28type CloseHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
29
30/// Overwrite `slot` only when `value` was explicitly set.
31fn merge_field<T>(slot: &mut Option<T>, value: Option<T>) {
32    if value.is_some() {
33        *slot = value;
34    }
35}
36
37/// Dialog button props.
38///
39/// Every field is unset until a builder sets it, and an unset field falls back
40/// to its documented default when the dialog renders. Handing a value to
41/// [`Dialog::button_props`] or [`crate::dialog::AlertDialog::button_props`]
42/// therefore overrides only the fields that value sets: whatever the dialog
43/// already carries — the Cancel button `AlertDialog::confirm` asked for, a
44/// callback an earlier `on_ok` installed — survives.
45#[derive(Clone, Default)]
46pub struct DialogButtonProps {
47    pub(crate) ok_text: Option<SharedString>,
48    pub(crate) ok_variant: Option<ButtonVariant>,
49    pub(crate) cancel_text: Option<SharedString>,
50    pub(crate) cancel_variant: Option<ButtonVariant>,
51    pub(crate) show_cancel: Option<bool>,
52    pub(crate) on_ok: Option<OkHandler>,
53    pub(crate) on_cancel: Option<CancelHandler>,
54    pub(crate) on_close: Option<CloseHandler>,
55}
56
57impl DialogButtonProps {
58    /// Sets the text of the OK button. Default is `OK`.
59    pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
60        self.ok_text = Some(ok_text.into());
61        self
62    }
63
64    /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
65    pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
66        self.ok_variant = Some(ok_variant);
67        self
68    }
69
70    /// Sets the text of the Cancel button. Default is `Cancel`.
71    pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
72        self.cancel_text = Some(cancel_text.into());
73        self
74    }
75
76    /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`.
77    pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
78        self.cancel_variant = Some(cancel_variant);
79        self
80    }
81
82    /// Sets whether to show the Cancel button. Default is `false`.
83    pub fn show_cancel(mut self, show_cancel: bool) -> Self {
84        self.show_cancel = Some(show_cancel);
85        self
86    }
87
88    /// Sets the callback for when the dialog is has been confirmed.
89    ///
90    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
91    pub fn on_ok(
92        mut self,
93        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
94    ) -> Self {
95        self.on_ok = Some(Rc::new(on_ok));
96        self
97    }
98
99    /// Sets the callback for when the dialog is has been canceled.
100    ///
101    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
102    pub fn on_cancel(
103        mut self,
104        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
105    ) -> Self {
106        self.on_cancel = Some(Rc::new(on_cancel));
107        self
108    }
109
110    /// Takes over every field `other` sets and keeps the rest.
111    pub(crate) fn merge(&mut self, other: Self) {
112        merge_field(&mut self.ok_text, other.ok_text);
113        merge_field(&mut self.ok_variant, other.ok_variant);
114        merge_field(&mut self.cancel_text, other.cancel_text);
115        merge_field(&mut self.cancel_variant, other.cancel_variant);
116        merge_field(&mut self.show_cancel, other.show_cancel);
117        merge_field(&mut self.on_ok, other.on_ok);
118        merge_field(&mut self.on_cancel, other.on_cancel);
119        merge_field(&mut self.on_close, other.on_close);
120    }
121
122    /// Whether the default footer renders a Cancel button. Default is `false`.
123    pub(crate) fn is_cancel_shown(&self) -> bool {
124        self.show_cancel.unwrap_or(false)
125    }
126
127    /// The confirm callback, defaulting to one that closes the dialog.
128    pub(crate) fn ok_handler(&self) -> OkHandler {
129        self.on_ok
130            .clone()
131            .unwrap_or_else(|| Rc::new(|_, _, _| true))
132    }
133
134    /// The cancel callback, defaulting to one that closes the dialog.
135    pub(crate) fn cancel_handler(&self) -> CancelHandler {
136        self.on_cancel
137            .clone()
138            .unwrap_or_else(|| Rc::new(|_, _, _| true))
139    }
140
141    /// The close callback, defaulting to one that does nothing.
142    pub(crate) fn close_handler(&self) -> CloseHandler {
143        self.on_close
144            .clone()
145            .unwrap_or_else(|| Rc::new(|_, _, _| {}))
146    }
147
148    pub(crate) fn render_ok(&self, _: &mut Window, _: &mut App) -> AnyElement {
149        let ok_text = self
150            .ok_text
151            .clone()
152            .unwrap_or_else(|| t!("Dialog.ok").into());
153
154        DialogButton {
155            anchor_key: "dialog-ok-anchor",
156            button: Button::new("ok")
157                .label(ok_text)
158                .with_variant(self.ok_variant.unwrap_or(ButtonVariant::Primary)),
159            action: Rc::new(Confirm { secondary: false }),
160        }
161        .into_any_element()
162    }
163
164    pub(crate) fn render_cancel(&self, _: &mut Window, _: &mut App) -> AnyElement {
165        let cancel_text = self
166            .cancel_text
167            .clone()
168            .unwrap_or_else(|| t!("Dialog.cancel").into());
169
170        DialogButton {
171            anchor_key: "dialog-cancel-anchor",
172            button: Button::new("cancel")
173                .label(cancel_text)
174                .with_variant(self.cancel_variant.unwrap_or_default()),
175            action: Rc::new(Cancel),
176        }
177        .into_any_element()
178    }
179}
180
181/// A default dialog button: activating it dispatches `action` on the dialog
182/// it sits in, whatever holds focus at that moment.
183#[derive(IntoElement)]
184struct DialogButton {
185    /// Distinct per button: OK and Cancel render as siblings in one scope.
186    anchor_key: &'static str,
187    button: Button,
188    action: Rc<dyn Action>,
189}
190
191impl RenderOnce for DialogButton {
192    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
193        let anchor = DialogDispatchAnchor::new(self.anchor_key, window, cx);
194        self.button
195            .child(anchor.element())
196            .on_click(move |_, window, cx| anchor.dispatch(&*self.action, window, cx))
197    }
198}
199
200type ContentBuilderFn = Rc<dyn Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static>;
201
202#[derive(Clone)]
203pub(crate) struct DialogProps {
204    width: Pixels,
205    max_width: Option<Pixels>,
206    margin_top: Option<Pixels>,
207    close_button: bool,
208
209    overlay: bool,
210    overlay_closable: bool,
211    pub(crate) overlay_visible: bool,
212    keyboard: bool,
213}
214
215impl Default for DialogProps {
216    fn default() -> Self {
217        Self {
218            margin_top: None,
219            width: px(448.),
220            max_width: None,
221            overlay: true,
222            keyboard: true,
223            overlay_visible: false,
224            close_button: true,
225            overlay_closable: true,
226        }
227    }
228}
229
230enum BaseDialogRoot {
231    Dialog(gpui_base::Dialog),
232    AlertDialog(gpui_base::AlertDialog),
233}
234
235macro_rules! map_base_root {
236    ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
237        match $self {
238            BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
239            BaseDialogRoot::AlertDialog(root) => {
240                BaseDialogRoot::AlertDialog(root.$method($($arg),*))
241            }
242        }
243    };
244}
245
246impl BaseDialogRoot {
247    fn layer(self, index: usize, topmost: bool) -> Self {
248        map_base_root!(self, layer(index, topmost))
249    }
250    fn focus_handle(self, focus: FocusHandle) -> Self {
251        map_base_root!(self, focus_handle(focus))
252    }
253    fn close_on_escape(self, value: bool) -> Self {
254        map_base_root!(self, close_on_escape(value))
255    }
256    fn close_on_backdrop_press(self, value: bool) -> Self {
257        match self {
258            Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
259            Self::AlertDialog(root) => Self::AlertDialog(root),
260        }
261    }
262    fn dismiss_below_y(self, value: Pixels) -> Self {
263        map_base_root!(self, dismiss_below_y(value))
264    }
265    fn backdrop(self, element: impl IntoElement) -> Self {
266        map_base_root!(self, backdrop(element))
267    }
268    fn popup(self, element: impl IntoElement) -> Self {
269        map_base_root!(self, popup(element))
270    }
271    fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
272        map_base_root!(self, on_ok(handler))
273    }
274    fn on_cancel(
275        self,
276        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
277    ) -> Self {
278        map_base_root!(self, on_cancel(handler))
279    }
280    fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
281        map_base_root!(self, on_close(handler))
282    }
283    fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
284        map_base_root!(self, request_close(handler))
285    }
286}
287
288impl IntoElement for BaseDialogRoot {
289    type Element = <gpui_base::Dialog as IntoElement>::Element;
290    fn into_element(self) -> Self::Element {
291        match self {
292            Self::Dialog(root) => root.into_element(),
293            Self::AlertDialog(root) => root.into_element(),
294        }
295    }
296}
297
298/// A modal to display content in a dialog box.
299#[derive(IntoElement)]
300pub struct Dialog {
301    base: Option<BaseDialogRoot>,
302    pub(crate) style: StyleRefinement,
303    children: Vec<AnyElement>,
304    trigger: Option<AnyElement>,
305    title: Option<AnyElement>,
306    pub(crate) header: Option<AnyElement>,
307    pub(crate) footer: Option<AnyElement>,
308    pub(crate) content_builder: Option<ContentBuilderFn>,
309    pub(crate) props: DialogProps,
310
311    pub(super) button_props: DialogButtonProps,
312
313    /// This will be change when open the dialog, the focus handle is create when open the dialog.
314    pub(crate) focus_handle: FocusHandle,
315    pub(crate) layer_ix: usize,
316    pub(crate) selection_scope: TextSelectionScopeId,
317}
318
319pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
320    if !overlay {
321        return hsla(0., 0., 0., 0.);
322    }
323
324    cx.theme().overlay
325}
326
327impl Dialog {
328    /// Create a new dialog.
329    pub fn new(cx: &mut App) -> Self {
330        Self {
331            base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
332            focus_handle: cx.focus_handle(),
333            style: StyleRefinement::default(),
334            trigger: None,
335            title: None,
336            header: None,
337            footer: None,
338            content_builder: None,
339            props: DialogProps::default(),
340            children: Vec::new(),
341            layer_ix: 0,
342            selection_scope: TextSelectionScopeId::default(),
343            button_props: DialogButtonProps::default(),
344        }
345    }
346
347    /// Sets the trigger element for the dialog.
348    ///
349    /// When a trigger is set, the dialog will render as a trigger button that opens the dialog when clicked.
350    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
351        self.trigger = Some(trigger.into_any_element());
352        self
353    }
354
355    /// Sets the content of the dialog.
356    pub fn content<F>(mut self, builder: F) -> Self
357    where
358        F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
359    {
360        self.content_builder = Some(Rc::new(builder));
361        self
362    }
363
364    /// Sets the title of the dialog.
365    pub fn title(mut self, title: impl IntoElement) -> Self {
366        self.title = Some(title.into_any_element());
367        self
368    }
369
370    /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons.
371    ///
372    /// When you set the footer, the `button_props` will be ignored, you need to render the action buttons by yourself.
373    pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
374        self.header = Some(header.into_any_element());
375        self
376    }
377
378    /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons.
379    ///
380    /// When you set the footer, the `button_props` will be ignored, you need to render the action buttons by yourself.
381    pub fn footer(mut self, footer: impl IntoElement) -> Self {
382        self.footer = Some(footer.into_any_element());
383        self
384    }
385
386    /// Set the button props of the dialog.
387    ///
388    /// This overrides only the fields `button_props` sets; the rest of the
389    /// dialog's button configuration is kept, so the call order does not
390    /// matter.
391    pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
392        self.button_props.merge(button_props);
393        self
394    }
395
396    pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
397        self.base = Some(BaseDialogRoot::AlertDialog(base));
398        self.props.overlay_closable = false;
399        self
400    }
401
402    /// Sets the callback for when the dialog is closed.
403    ///
404    /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
405    pub fn on_close(
406        mut self,
407        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
408    ) -> Self {
409        self.button_props.on_close = Some(Rc::new(on_close));
410        self
411    }
412
413    /// Sets the callback for when the dialog is has been confirmed.
414    ///
415    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
416    pub fn on_ok(
417        mut self,
418        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
419    ) -> Self {
420        self.button_props = self.button_props.on_ok(on_ok);
421        self
422    }
423
424    /// Sets the callback for when the dialog is has been canceled.
425    ///
426    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
427    pub fn on_cancel(
428        mut self,
429        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
430    ) -> Self {
431        self.button_props = self.button_props.on_cancel(on_cancel);
432        self
433    }
434
435    /// Sets the false to hide close icon, default: true
436    pub fn close_button(mut self, close_button: bool) -> Self {
437        self.props.close_button = close_button;
438        self
439    }
440
441    /// Set the top offset of the dialog, defaults to None, will use the 1/10 of the viewport height.
442    pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
443        self.props.margin_top = Some(margin_top.into());
444        self
445    }
446
447    /// Sets the width of the dialog, defaults to 448px.
448    ///
449    /// The dialog is never wider than the viewport minus a margin on each side.
450    ///
451    /// See also [`Self::width`]
452    pub fn w(mut self, width: impl Into<Pixels>) -> Self {
453        self.props.width = width.into();
454        self
455    }
456
457    /// Sets the width of the dialog, defaults to 448px.
458    ///
459    /// The dialog is never wider than the viewport minus a margin on each side.
460    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
461        self.props.width = width.into();
462        self
463    }
464
465    /// Set the maximum width of the dialog, defaults to `None`.
466    pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
467        self.props.max_width = Some(max_width.into());
468        self
469    }
470
471    /// Set the overlay of the dialog, defaults to `true`.
472    pub fn overlay(mut self, overlay: bool) -> Self {
473        self.props.overlay = overlay;
474        self
475    }
476
477    /// Set the overlay closable of the dialog, defaults to `true`.
478    ///
479    /// When the overlay is clicked, the dialog will be closed.
480    pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
481        self.props.overlay_closable = overlay_closable;
482        self
483    }
484
485    /// Set whether to support keyboard esc to close the dialog, defaults to `true`.
486    pub fn keyboard(mut self, keyboard: bool) -> Self {
487        self.props.keyboard = keyboard;
488        self
489    }
490
491    pub(crate) fn has_overlay(&self) -> bool {
492        self.props.overlay
493    }
494
495    pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
496        self.props = props;
497        self
498    }
499
500    fn defer_close_dialog(window: &mut Window, cx: &mut App) {
501        WindowState::update(window, cx, |root, window, cx| {
502            root.defer_close_dialog(window, cx);
503        });
504    }
505}
506
507impl ParentElement for Dialog {
508    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
509        self.children.extend(elements);
510    }
511}
512
513impl Styled for Dialog {
514    fn style(&mut self) -> &mut gpui::StyleRefinement {
515        &mut self.style
516    }
517}
518
519impl Dialog {
520    fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
521        let content_builder = self.content_builder.clone();
522        let style = self.style.clone();
523        let props = self.props.clone();
524        let button_props = self.button_props.clone();
525
526        gpui_base::DialogTrigger::new(trigger)
527            .on_open(move |window, cx| {
528                let content_builder = content_builder.clone();
529                let style = style.clone();
530                let props = props.clone();
531                let button_props = button_props.clone();
532                window.open_dialog(cx, move |dialog, _, _| {
533                    dialog
534                        .refine_style(&style)
535                        .button_props(button_props.clone())
536                        .with_props(props.clone())
537                        .content({
538                            let content_builder = content_builder.clone();
539                            move |content, window, cx| {
540                                if let Some(builder) = content_builder.clone() {
541                                    builder(content, window, cx)
542                                } else {
543                                    content
544                                }
545                            }
546                        })
547                });
548            })
549            .into_any_element()
550    }
551}
552
553impl RenderOnce for Dialog {
554    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
555        if let Some(trigger) = self.trigger.take() {
556            return self.render_trigger(trigger, window, cx);
557        }
558
559        let layer_ix = self.layer_ix;
560        let selection_scope = self.selection_scope;
561        let on_close = self.button_props.close_handler();
562        let on_ok = self.button_props.ok_handler();
563        let on_cancel = self.button_props.cancel_handler();
564
565        let window_paddings = crate::window_border::window_paddings(window);
566        let view_size = window.viewport_size()
567            - gpui::size(
568                window_paddings.left + window_paddings.right,
569                window_paddings.top + window_paddings.bottom,
570            );
571        // The dialog keeps this much of the viewport clear around its edges,
572        // so a small window shrinks the surface instead of letting it run off
573        // the window. A dialog that fits keeps `margin_top` (a tenth of the
574        // viewport by default); an overflowing one is snapped up to this edge
575        // margin so that the preferred offset does not waste usable height.
576        let margin = cx.theme().spacing_tokens().lg;
577        let layer_offset = px(layer_ix as f32 * 16.);
578        let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + layer_offset;
579        let width = self
580            .props
581            .width
582            .min((view_size.width - margin * 2.).max(px(0.)));
583        let x = (view_size.width - width) / 2.;
584        let max_height = (view_size.height - margin * 2. - layer_offset).max(px(0.));
585
586        let base_size = window.text_style().font_size;
587        let rem_size = window.rem_size();
588
589        let mut paddings = Edges::all(px(16.));
590        if let Some(pl) = self.style.padding.left {
591            paddings.left = pl.to_pixels(base_size, rem_size);
592        }
593        if let Some(pr) = self.style.padding.right {
594            paddings.right = pr.to_pixels(base_size, rem_size);
595        }
596        if let Some(pt) = self.style.padding.top {
597            paddings.top = pt.to_pixels(base_size, rem_size);
598        }
599        if let Some(pb) = self.style.padding.bottom {
600            paddings.bottom = pb.to_pixels(base_size, rem_size);
601        }
602
603        // x1 = 1/3, x2 = 2/3 make the bezier's time mapping the identity,
604        // preserving the trajectory this dialog was tuned with before
605        // `cubic_bezier` solved for x; vaul's (0.32, 0.72, 0., 1.) is far
606        // more front-loaded under the CSS-correct solver.
607        let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
608            1. / 3.,
609            0.72,
610            2. / 3.,
611            1.,
612        ));
613
614        anchored()
615            .position(point(window_paddings.left, window_paddings.top))
616            .snap_to_window()
617            .child(
618                div()
619                    .id("dialog")
620                    .test_support()
621                    .occlude()
622                    .w(view_size.width)
623                    .h(view_size.height)
624                    .child(
625                        self.base
626                            .take()
627                            .expect("Dialog base host is always present")
628                            .layer(
629                                layer_ix,
630                                (self.layer_ix + 1)
631                                    == WindowState::read(window, cx).active_dialogs.len(),
632                            )
633                            .focus_handle(self.focus_handle.clone())
634                            .close_on_escape(self.props.keyboard)
635                            .close_on_backdrop_press(self.props.overlay_closable)
636                            .dismiss_below_y(TITLE_BAR_HEIGHT)
637                            .when(self.props.overlay, |this| {
638                                this.backdrop(
639                                    div()
640                                        .absolute()
641                                        .size_full()
642                                        .window_control_area(WindowControlArea::Drag)
643                                        .when(self.props.overlay_visible, |overlay| {
644                                            overlay.bg(overlay_color(true, cx))
645                                        }),
646                                )
647                            })
648                            .on_ok(move |event, window, cx| on_ok(event, window, cx))
649                            .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
650                            .on_close(move |event, window, cx| on_close(event, window, cx))
651                            .request_close(move |deferred, window, cx| {
652                                if deferred {
653                                    Self::defer_close_dialog(window, cx);
654                                } else {
655                                    window.close_dialog(cx);
656                                }
657                            })
658                            .popup(
659                                gpui_base::Positioner::corner(
660                                    Anchor::TopLeft,
661                                    point(window_paddings.left + x, window_paddings.top + y),
662                                )
663                                .margin(margin)
664                                .child(
665                                    v_flex()
666                                        .id(layer_ix)
667                                        .test_support()
668                                        .debug_selector(move || format!("dialog-{layer_ix}"))
669                                        .bg(cx.theme().tokens.background)
670                                        .border_1()
671                                        .border_color(cx.theme().border)
672                                        .rounded(cx.theme().radius_lg)
673                                        .min_h_24()
674                                        .pt(paddings.top)
675                                        .pb(paddings.bottom)
676                                        .gap(paddings.top.max(px(8.)))
677                                        .refine_style(&self.style)
678                                        .px_0()
679                                        .occlude()
680                                        .w(width)
681                                        .when_some(self.props.max_width, |this, w| this.max_w(w))
682                                        .max_h(max_height)
683                                        .child(
684                                            v_flex()
685                                                .flex_1()
686                                                .overflow_hidden()
687                                                .gap_y_2()
688                                                .when_some(self.header, |this, header| {
689                                                    this.child(
690                                                        div()
691                                                            .pl(paddings.left)
692                                                            .pr(paddings.right)
693                                                            .child(header),
694                                                    )
695                                                })
696                                                .when_some(self.title, |this, title| {
697                                                    this.child(
698                                                        DialogTitle::new()
699                                                            .pl(paddings.left)
700                                                            .pr(paddings.right)
701                                                            .child(title),
702                                                    )
703                                                })
704                                                .when_some(self.content_builder, |this, builder| {
705                                                    this.child(builder(
706                                                        DialogContent::new()
707                                                            .gap(paddings.bottom)
708                                                            .pl(paddings.left)
709                                                            .pr(paddings.right),
710                                                        window,
711                                                        cx,
712                                                    ))
713                                                })
714                                                .when(!self.children.is_empty(), |this| {
715                                                    this.child(
716                                                        div().flex_1().overflow_hidden().child(
717                                                            // Body
718                                                            v_flex()
719                                                                .size_full()
720                                                                .overflow_y_scrollbar()
721                                                                .pl(paddings.left)
722                                                                .pr(paddings.right)
723                                                                .children(self.children),
724                                                        ),
725                                                    )
726                                                }),
727                                        )
728                                        .when_some(self.footer, |this, footer| {
729                                            this.child(
730                                                div()
731                                                    .pl(paddings.left)
732                                                    .pr(paddings.right)
733                                                    .child(footer),
734                                            )
735                                        })
736                                        .children(self.props.close_button.then(|| {
737                                            let top = (paddings.top - px(10.)).max(px(8.));
738                                            let right = (paddings.right - px(10.)).max(px(8.));
739
740                                            gpui_base::DialogClose::new()
741                                                .absolute()
742                                                .top(top)
743                                                .right(right)
744                                                .trigger(|button| {
745                                                    Button::new("close")
746                                                        .with_base(button)
747                                                        .small()
748                                                        .ghost()
749                                                        .icon(IconName::Close)
750                                                })
751                                        }))
752                                        .with_animation(
753                                            "slide-down-shadow",
754                                            animation.clone(),
755                                            move |this, delta| {
756                                                // This is equivalent to `shadow_xl` with an extra opacity.
757                                                let shadow = vec![
758                                                    BoxShadow {
759                                                        color: hsla(0., 0., 0., 0.1 * delta),
760                                                        offset: point(px(0.), px(20.)),
761                                                        blur_radius: px(25.),
762                                                        spread_radius: px(-5.),
763                                                        inset: false,
764                                                    },
765                                                    BoxShadow {
766                                                        color: hsla(0., 0., 0., 0.1 * delta),
767                                                        offset: point(px(0.), px(8.)),
768                                                        blur_radius: px(10.),
769                                                        spread_radius: px(-6.),
770                                                        inset: false,
771                                                    },
772                                                ];
773                                                this.shadow(shadow)
774                                            },
775                                        )
776                                        .text_selection_scope(selection_scope),
777                                )
778                                .with_animation(
779                                    "slide-down",
780                                    animation.clone(),
781                                    move |this, delta| {
782                                        this.position(point(
783                                            window_paddings.left + x,
784                                            window_paddings.top + y * delta,
785                                        ))
786                                    },
787                                ),
788                            ),
789                    )
790                    .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
791            )
792            .into_any_element()
793    }
794}
795
796#[cfg(test)]
797pub(crate) mod tests {
798    use super::*;
799    use gpui::{AppContext as _, Bounds, Context, Render, TestAppContext, VisualTestContext, size};
800
801    struct DialogHost;
802
803    impl Render for DialogHost {
804        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
805            div().size_full()
806        }
807    }
808
809    /// A window of `window_size` whose root renders the dialog layer, with
810    /// motion reduced so the entrance animation settles on its first frame.
811    pub(crate) fn window(
812        cx: &mut TestAppContext,
813        window_size: gpui::Size<Pixels>,
814    ) -> &mut VisualTestContext {
815        cx.update(|cx| {
816            crate::init(cx);
817            cx.set_reduce_motion(true);
818        });
819        let (_, cx) = cx.add_window_view(|window, cx| {
820            let view = cx.new(|_| DialogHost);
821            crate::Root::new(view, window, cx)
822        });
823        cx.simulate_resize(window_size);
824        cx.update(|window, cx| window.draw(cx).clear(cx));
825        cx
826    }
827
828    fn open(
829        cx: &mut VisualTestContext,
830        build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
831    ) {
832        cx.update(|window, cx| window.open_dialog(cx, build));
833        cx.run_until_parked();
834        // One frame mounts the layer, the next paints it at rest.
835        cx.update(|window, cx| window.draw(cx).clear(cx));
836        cx.update(|window, cx| window.draw(cx).clear(cx));
837    }
838
839    fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
840        let selector = ["dialog-0", "dialog-1"][layer_ix];
841        cx.debug_bounds(selector)
842            .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
843    }
844
845    /// The clamp must not touch a dialog that already fits: the default width
846    /// and the tenth-of-the-viewport top offset are the documented contract.
847    #[gpui::test]
848    fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
849        let cx = window(cx, size(px(1000.), px(800.)));
850        open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
851
852        let bounds = surface(cx, 0);
853        assert_eq!(bounds.size.width, px(448.));
854        assert_eq!(bounds.origin.x, px(276.));
855        assert_eq!(bounds.origin.y, px(80.));
856    }
857
858    /// A dialog wider and taller than the window must shrink to the viewport
859    /// instead of running off both edges. It also gives up the usual top
860    /// offset so that space is available to its content, while its footer
861    /// remains inside the surface rather than clipped below it.
862    #[gpui::test]
863    fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
864        let viewport = size(px(400.), px(300.));
865        let cx = window(cx, viewport);
866        open(cx, |dialog, _, _| {
867            dialog
868                .w(px(800.))
869                .title("Too big")
870                .child(div().h(px(1000.)).child("tall body"))
871                .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
872        });
873
874        let bounds = surface(cx, 0);
875        let footer = cx.debug_bounds("footer-probe").unwrap();
876        let margin = px(16.);
877        assert!(
878            bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
879            "the dialog ran off the sides: {bounds:?}"
880        );
881        assert!(
882            bounds.bottom() <= viewport.height - margin,
883            "the dialog ran off the bottom: {bounds:?}"
884        );
885        assert_eq!(bounds.origin.y, margin);
886        assert!(
887            footer.bottom() <= bounds.bottom(),
888            "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
889        );
890    }
891
892    /// `Dialog::button_props` overrides only the fields it sets.
893    #[gpui::test]
894    fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
895        let cx = window(cx, size(px(400.), px(300.)));
896        cx.update(|_, cx| {
897            let dialog = Dialog::new(cx)
898                .button_props(DialogButtonProps::default().cancel_text("Keep"))
899                .button_props(DialogButtonProps::default().ok_text("Delete"))
900                .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
901
902            assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
903            assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
904            assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
905        });
906    }
907
908    /// Each stacked dialog steps down 16px; the deepest one must still end
909    /// above the bottom margin.
910    #[gpui::test]
911    fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
912        let viewport = size(px(400.), px(300.));
913        let cx = window(cx, viewport);
914        open(cx, |dialog, _, _| {
915            dialog.title("First").child(div().h(px(1000.)))
916        });
917        open(cx, |dialog, _, _| {
918            dialog.title("Second").child(div().h(px(1000.)))
919        });
920
921        let first = surface(cx, 0);
922        let second = surface(cx, 1);
923        assert_eq!(second.origin.y, first.origin.y + px(16.));
924        assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
925        assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
926        assert!(second.size.height < first.size.height);
927    }
928}