Skip to main content

gpui_component/dialog/
dialog.rs

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