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