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            // The delta drives opacity, so keep an overshooting theme curve in range.
663            Animation::new(motion.duration_normal)
664                .with_easing(move |delta| easing.sample(delta).clamp(0., 1.))
665        };
666
667        anchored()
668            .position(point(window_paddings.left, window_paddings.top))
669            .snap_to_window()
670            .child(
671                div()
672                    .id("dialog")
673                    .test_support()
674                    .occlude()
675                    .w(view_size.width)
676                    .h(view_size.height)
677                    .child(
678                        self.base
679                            .take()
680                            .expect("Dialog base host is always present")
681                            .layer(
682                                layer_ix,
683                                (self.layer_ix + 1)
684                                    == WindowState::read(window, cx).active_dialogs.len(),
685                            )
686                            .focus_handle(self.focus_handle.clone())
687                            .close_on_escape(self.props.keyboard)
688                            .close_on_backdrop_press(self.props.overlay_closable)
689                            .dismiss_below_y(TITLE_BAR_HEIGHT)
690                            .when(self.props.overlay, |this| {
691                                this.backdrop(
692                                    div()
693                                        .absolute()
694                                        .size_full()
695                                        .window_control_area(WindowControlArea::Drag)
696                                        .when(self.props.overlay_visible, |overlay| {
697                                            overlay.bg(overlay_color(true, cx))
698                                        }),
699                                )
700                            })
701                            .on_ok(move |event, window, cx| on_ok(event, window, cx))
702                            .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
703                            .on_close(move |event, window, cx| on_close(event, window, cx))
704                            .request_close(move |deferred, window, cx| {
705                                if deferred {
706                                    Self::defer_close_dialog(window, cx);
707                                } else {
708                                    window.close_dialog(cx);
709                                }
710                            })
711                            .popup(
712                                gpui_base::Positioner::corner(
713                                    Anchor::TopLeft,
714                                    point(window_paddings.left + x, window_paddings.top + y),
715                                )
716                                .margin(margin)
717                                .child(
718                                    v_flex()
719                                        .id(layer_ix)
720                                        .test_support()
721                                        .debug_selector(move || format!("dialog-{layer_ix}"))
722                                        .bg(cx.theme().tokens.background)
723                                        .border_1()
724                                        .border_color(cx.theme().border)
725                                        .rounded(cx.theme().radius_lg)
726                                        .min_h_24()
727                                        .pt(paddings.top)
728                                        .pb(paddings.bottom)
729                                        .gap(paddings.top.max(px(8.)))
730                                        .refine_style(&self.style)
731                                        .px_0()
732                                        .occlude()
733                                        .w(width)
734                                        .when_some(self.props.max_width, |this, w| this.max_w(w))
735                                        .max_h(max_height)
736                                        .child(
737                                            v_flex()
738                                                .flex_1()
739                                                .overflow_hidden()
740                                                .gap_y_2()
741                                                .when_some(self.header, |this, header| {
742                                                    this.child(
743                                                        div()
744                                                            .pl(paddings.left)
745                                                            .pr(paddings.right)
746                                                            .child(header),
747                                                    )
748                                                })
749                                                .when_some(self.title, |this, title| {
750                                                    this.child(
751                                                        DialogTitle::new()
752                                                            .pl(paddings.left)
753                                                            .pr(paddings.right)
754                                                            .child(title),
755                                                    )
756                                                })
757                                                .when_some(self.content_builder, |this, builder| {
758                                                    this.child(builder(
759                                                        DialogContent::new()
760                                                            .gap(paddings.bottom)
761                                                            .pl(paddings.left)
762                                                            .pr(paddings.right),
763                                                        window,
764                                                        cx,
765                                                    ))
766                                                })
767                                                .when(!self.children.is_empty(), |this| {
768                                                    this.child(
769                                                        div().flex_1().overflow_hidden().child(
770                                                            // Body
771                                                            v_flex()
772                                                                .size_full()
773                                                                .overflow_y_scrollbar()
774                                                                .pl(paddings.left)
775                                                                .pr(paddings.right)
776                                                                .children(self.children),
777                                                        ),
778                                                    )
779                                                }),
780                                        )
781                                        .when_some(self.footer, |this, footer| {
782                                            this.child(
783                                                div()
784                                                    .pl(paddings.left)
785                                                    .pr(paddings.right)
786                                                    .child(footer),
787                                            )
788                                        })
789                                        .children(self.props.close_button.then(|| {
790                                            let top = (paddings.top - px(10.)).max(px(8.));
791                                            let right = (paddings.right - px(10.)).max(px(8.));
792
793                                            gpui_base::DialogClose::new()
794                                                .absolute()
795                                                .top(top)
796                                                .right(right)
797                                                .trigger(|button| {
798                                                    Button::new("close")
799                                                        .with_base(button)
800                                                        .small()
801                                                        .ghost()
802                                                        .icon(IconName::Close)
803                                                })
804                                        }))
805                                        .map(|surface| {
806                                            if animated {
807                                                surface
808                                                    .with_animation(
809                                                        "slide-down-shadow",
810                                                        animation.clone(),
811                                                        |surface, delta| {
812                                                            surface.shadow(dialog_shadow(delta))
813                                                        },
814                                                    )
815                                                    .text_selection_scope(selection_scope)
816                                                    .into_any_element()
817                                            } else {
818                                                surface
819                                                    .shadow(dialog_shadow(1.))
820                                                    .text_selection_scope(selection_scope)
821                                                    .into_any_element()
822                                            }
823                                        })
824                                        .map(|surface| {
825                                            if animated && entrance == DialogEntrance::FadeSlide {
826                                                EntranceSurface::new(
827                                                    surface,
828                                                    travel,
829                                                    window_paddings.top + margin,
830                                                )
831                                                .with_animation(
832                                                    "dialog-settle",
833                                                    animation.clone(),
834                                                    |mut surface, delta| {
835                                                        surface.progress = delta;
836                                                        surface
837                                                    },
838                                                )
839                                                .into_any_element()
840                                            } else {
841                                                surface
842                                            }
843                                        }),
844                                )
845                                .map(|positioner| {
846                                    if animated && entrance == DialogEntrance::SlideDown {
847                                        positioner
848                                            .with_animation(
849                                                "slide-down",
850                                                animation.clone(),
851                                                move |this, delta| {
852                                                    this.position(point(
853                                                        window_paddings.left + x,
854                                                        window_paddings.top + y * delta,
855                                                    ))
856                                                },
857                                            )
858                                            .into_any_element()
859                                    } else {
860                                        positioner.into_any_element()
861                                    }
862                                }),
863                            ),
864                    )
865                    .map(|layer| {
866                        if animated {
867                            layer
868                                .with_animation("fade-in", animation, |layer, delta| {
869                                    layer.opacity(delta)
870                                })
871                                .into_any_element()
872                        } else {
873                            layer.into_any_element()
874                        }
875                    }),
876            )
877            .into_any_element()
878    }
879}
880
881#[cfg(test)]
882pub(crate) mod tests {
883    use super::*;
884    use gpui::{AppContext as _, Bounds, Context, Render, TestAppContext, VisualTestContext, size};
885
886    struct DialogHost;
887
888    impl Render for DialogHost {
889        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
890            div().size_full()
891        }
892    }
893
894    /// A window of `window_size` whose root renders the dialog layer, with
895    /// motion reduced so the entrance animation settles on its first frame.
896    pub(crate) fn window(
897        cx: &mut TestAppContext,
898        window_size: gpui::Size<Pixels>,
899    ) -> &mut VisualTestContext {
900        cx.update(|cx| {
901            crate::init(cx);
902            cx.set_reduce_motion(true);
903        });
904        let (_, cx) = cx.add_window_view(|window, cx| {
905            let view = cx.new(|_| DialogHost);
906            crate::Root::new(view, window, cx)
907        });
908        cx.simulate_resize(window_size);
909        cx.update(|window, cx| window.draw(cx).clear(cx));
910        cx
911    }
912
913    fn open(
914        cx: &mut VisualTestContext,
915        build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
916    ) {
917        cx.update(|window, cx| window.open_dialog(cx, build));
918        cx.run_until_parked();
919        // One frame mounts the layer, the next paints it at rest.
920        cx.update(|window, cx| window.draw(cx).clear(cx));
921        cx.update(|window, cx| window.draw(cx).clear(cx));
922    }
923
924    fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
925        let selector = ["dialog-0", "dialog-1"][layer_ix];
926        cx.debug_bounds(selector)
927            .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
928    }
929
930    /// The clamp must not touch a dialog that already fits: the default width
931    /// and the tenth-of-the-viewport top offset are the documented contract.
932    #[gpui::test]
933    fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
934        let cx = window(cx, size(px(1000.), px(800.)));
935        open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
936
937        let bounds = surface(cx, 0);
938        assert_eq!(bounds.size.width, px(448.));
939        assert_eq!(bounds.origin.x, px(276.));
940        assert_eq!(bounds.origin.y, px(80.));
941    }
942
943    /// A dialog wider and taller than the window must shrink to the viewport
944    /// instead of running off both edges. It also gives up the usual top
945    /// offset so that space is available to its content, while its footer
946    /// remains inside the surface rather than clipped below it.
947    #[gpui::test]
948    fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
949        let viewport = size(px(400.), px(300.));
950        let cx = window(cx, viewport);
951        open(cx, |dialog, _, _| {
952            dialog
953                .w(px(800.))
954                .title("Too big")
955                .child(div().h(px(1000.)).child("tall body"))
956                .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
957        });
958
959        let bounds = surface(cx, 0);
960        let footer = cx.debug_bounds("footer-probe").unwrap();
961        let margin = px(16.);
962        assert!(
963            bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
964            "the dialog ran off the sides: {bounds:?}"
965        );
966        assert!(
967            bounds.bottom() <= viewport.height - margin,
968            "the dialog ran off the bottom: {bounds:?}"
969        );
970        assert_eq!(bounds.origin.y, margin);
971        assert!(
972            footer.bottom() <= bounds.bottom(),
973            "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
974        );
975    }
976
977    /// `Dialog::button_props` overrides only the fields it sets.
978    #[gpui::test]
979    fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
980        let cx = window(cx, size(px(400.), px(300.)));
981        cx.update(|_, cx| {
982            let dialog = Dialog::new(cx)
983                .button_props(DialogButtonProps::default().cancel_text("Keep"))
984                .button_props(DialogButtonProps::default().ok_text("Delete"))
985                .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
986
987            assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
988            assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
989            assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
990        });
991    }
992
993    /// Each stacked dialog steps down 16px; the deepest one must still end
994    /// above the bottom margin.
995    #[gpui::test]
996    fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
997        let viewport = size(px(400.), px(300.));
998        let cx = window(cx, viewport);
999        open(cx, |dialog, _, _| {
1000            dialog.title("First").child(div().h(px(1000.)))
1001        });
1002        open(cx, |dialog, _, _| {
1003            dialog.title("Second").child(div().h(px(1000.)))
1004        });
1005
1006        let first = surface(cx, 0);
1007        let second = surface(cx, 1);
1008        assert_eq!(second.origin.y, first.origin.y + px(16.));
1009        assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
1010        assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
1011        assert!(second.size.height < first.size.height);
1012    }
1013
1014    #[gpui::test]
1015    fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
1016        cx.update(crate::init);
1017        cx.update(|cx| {
1018            assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
1019            for entrance in [
1020                DialogEntrance::SlideDown,
1021                DialogEntrance::Fade,
1022                DialogEntrance::FadeSlide,
1023                DialogEntrance::None,
1024            ] {
1025                assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
1026            }
1027        });
1028    }
1029
1030    #[gpui::test]
1031    fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
1032        for entrance in [
1033            DialogEntrance::SlideDown,
1034            DialogEntrance::Fade,
1035            DialogEntrance::FadeSlide,
1036            DialogEntrance::None,
1037        ] {
1038            let cx = window(cx, size(px(1000.), px(800.)));
1039            open(cx, move |dialog, _, _| {
1040                dialog
1041                    .title("Entrance")
1042                    .child("body")
1043                    .entrance(entrance)
1044                    .margin_top(px(300.))
1045            });
1046            assert_eq!(surface(cx, 0).top(), px(300.));
1047        }
1048    }
1049
1050    #[gpui::test]
1051    fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
1052        let cx = window(cx, size(px(1000.), px(800.)));
1053        cx.update(|_, cx| cx.set_reduce_motion(false));
1054        open(cx, |dialog, _, _| {
1055            dialog
1056                .title("Immediate")
1057                .child("body")
1058                .entrance(DialogEntrance::None)
1059                .margin_top(px(300.))
1060        });
1061        assert_eq!(surface(cx, 0).top(), px(300.));
1062        cx.simulate_keystrokes("escape");
1063        cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
1064    }
1065
1066    #[gpui::test]
1067    fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
1068        for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1069            let cx = window(cx, size(px(1000.), px(800.)));
1070            cx.update(|_, cx| {
1071                cx.set_reduce_motion(false);
1072                crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1073            });
1074            open(cx, move |dialog, _, _| {
1075                dialog
1076                    .title("Immediate")
1077                    .child("body")
1078                    .entrance(entrance)
1079                    .margin_top(px(300.))
1080            });
1081            assert_eq!(surface(cx, 0).top(), px(300.));
1082        }
1083    }
1084
1085    #[gpui::test]
1086    fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
1087        for entrance in [
1088            DialogEntrance::SlideDown,
1089            DialogEntrance::Fade,
1090            DialogEntrance::FadeSlide,
1091            DialogEntrance::None,
1092        ] {
1093            let cx = window(cx, size(px(1000.), px(800.)));
1094            cx.update(|window, cx| {
1095                let alert = crate::dialog::AlertDialog::new(cx)
1096                    .title("Alert")
1097                    .entrance(entrance);
1098                assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
1099                window.open_alert_dialog(cx, move |alert, _, _| {
1100                    alert.title("Alert").entrance(entrance)
1101                });
1102            });
1103            cx.run_until_parked();
1104            cx.update(|window, cx| window.draw(cx).clear(cx));
1105            cx.update(|window, cx| window.draw(cx).clear(cx));
1106            assert_eq!(surface(cx, 0).top(), px(80.));
1107        }
1108    }
1109}