Skip to main content

gpui_component/dialog/
dialog.rs

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