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, AnyElement, App, BoxShadow, ClickEvent, Edges, FocusHandle, Hsla,
8    InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
9    StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point,
10    prelude::FluentBuilder, px,
11};
12use gpui_base::{ElementExt as _, TextSelectionScopeId, motion::Transition};
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    centered: bool,
209    close_button: bool,
210
211    overlay: bool,
212    overlay_closable: bool,
213    pub(crate) overlay_visible: bool,
214    keyboard: bool,
215    entrance: DialogEntrance,
216}
217
218impl Default for DialogProps {
219    fn default() -> Self {
220        Self {
221            margin_top: None,
222            centered: false,
223            width: px(448.),
224            max_width: None,
225            overlay: true,
226            keyboard: true,
227            overlay_visible: false,
228            close_button: true,
229            overlay_closable: true,
230            entrance: DialogEntrance::default(),
231        }
232    }
233}
234
235enum BaseDialogRoot {
236    Dialog(gpui_base::Dialog),
237    AlertDialog(gpui_base::AlertDialog),
238}
239
240macro_rules! map_base_root {
241    ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
242        match $self {
243            BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
244            BaseDialogRoot::AlertDialog(root) => {
245                BaseDialogRoot::AlertDialog(root.$method($($arg),*))
246            }
247        }
248    };
249}
250
251impl BaseDialogRoot {
252    fn layer(self, index: usize, topmost: bool) -> Self {
253        map_base_root!(self, layer(index, topmost))
254    }
255    fn focus_handle(self, focus: FocusHandle) -> Self {
256        map_base_root!(self, focus_handle(focus))
257    }
258    fn close_on_escape(self, value: bool) -> Self {
259        map_base_root!(self, close_on_escape(value))
260    }
261    fn close_on_backdrop_press(self, value: bool) -> Self {
262        match self {
263            Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
264            Self::AlertDialog(root) => Self::AlertDialog(root),
265        }
266    }
267    fn dismiss_below_y(self, value: Pixels) -> Self {
268        map_base_root!(self, dismiss_below_y(value))
269    }
270    fn backdrop(self, element: impl IntoElement) -> Self {
271        map_base_root!(self, backdrop(element))
272    }
273    fn popup(self, element: impl IntoElement) -> Self {
274        map_base_root!(self, popup(element))
275    }
276    fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
277        map_base_root!(self, on_ok(handler))
278    }
279    fn on_cancel(
280        self,
281        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
282    ) -> Self {
283        map_base_root!(self, on_cancel(handler))
284    }
285    fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
286        map_base_root!(self, on_close(handler))
287    }
288    fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
289        map_base_root!(self, request_close(handler))
290    }
291}
292
293impl IntoElement for BaseDialogRoot {
294    type Element = <gpui_base::Dialog as IntoElement>::Element;
295    fn into_element(self) -> Self::Element {
296        match self {
297            Self::Dialog(root) => root.into_element(),
298            Self::AlertDialog(root) => root.into_element(),
299        }
300    }
301}
302
303/// The shared entrance and exit sample for a mounted dialog.
304///
305/// Built by the window dialog layer with stable per-dialog identity.
306#[derive(Clone, Copy, Debug)]
307pub(crate) struct DialogMotion {
308    pub(crate) visibility: f32,
309    pub(crate) closing: bool,
310}
311
312/// A modal to display content in a dialog box.
313#[derive(IntoElement)]
314pub struct Dialog {
315    base: Option<BaseDialogRoot>,
316    pub(crate) style: StyleRefinement,
317    children: Vec<AnyElement>,
318    trigger: Option<AnyElement>,
319    title: Option<AnyElement>,
320    pub(crate) header: Option<AnyElement>,
321    pub(crate) footer: Option<AnyElement>,
322    pub(crate) content_builder: Option<ContentBuilderFn>,
323    pub(crate) props: DialogProps,
324
325    pub(super) button_props: DialogButtonProps,
326
327    /// This will be change when open the dialog, the focus handle is create when open the dialog.
328    pub(crate) focus_handle: FocusHandle,
329    pub(crate) layer_ix: usize,
330    pub(crate) selection_scope: TextSelectionScopeId,
331    pub(crate) motion: Option<DialogMotion>,
332    pub(crate) id: usize,
333}
334
335pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
336    if !overlay {
337        return hsla(0., 0., 0., 0.);
338    }
339
340    cx.theme().overlay
341}
342
343fn dialog_shadow(delta: f32) -> Vec<BoxShadow> {
344    vec![
345        BoxShadow {
346            color: hsla(0., 0., 0., 0.1 * delta),
347            offset: point(px(0.), px(20.)),
348            blur_radius: px(25.),
349            spread_radius: px(-5.),
350            inset: false,
351        },
352        BoxShadow {
353            color: hsla(0., 0., 0., 0.1 * delta),
354            offset: point(px(0.), px(8.)),
355            blur_radius: px(10.),
356            spread_radius: px(-6.),
357            inset: false,
358        },
359    ]
360}
361
362impl Dialog {
363    /// Create a new dialog.
364    pub fn new(cx: &mut App) -> Self {
365        Self {
366            base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
367            focus_handle: cx.focus_handle(),
368            style: StyleRefinement::default(),
369            trigger: None,
370            title: None,
371            header: None,
372            footer: None,
373            content_builder: None,
374            props: DialogProps::default(),
375            children: Vec::new(),
376            layer_ix: 0,
377            selection_scope: TextSelectionScopeId::default(),
378            button_props: DialogButtonProps::default(),
379            motion: None,
380            id: 0,
381        }
382    }
383
384    /// Sets the trigger element for the dialog.
385    ///
386    /// When a trigger is set, the dialog will render as a trigger button that opens the dialog when clicked.
387    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
388        self.trigger = Some(trigger.into_any_element());
389        self
390    }
391
392    /// Sets the content of the dialog.
393    pub fn content<F>(mut self, builder: F) -> Self
394    where
395        F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
396    {
397        self.content_builder = Some(Rc::new(builder));
398        self
399    }
400
401    /// Sets the title of the dialog.
402    pub fn title(mut self, title: impl IntoElement) -> Self {
403        self.title = Some(title.into_any_element());
404        self
405    }
406
407    /// Sets the header of the dialog, rendered above the title and content.
408    pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
409        self.header = Some(header.into_any_element());
410        self
411    }
412
413    /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons.
414    ///
415    /// A plain `Dialog` never renders buttons from `button_props`: the props
416    /// only install the callbacks behind the `Confirm` and `Cancel` actions.
417    /// Compose the action buttons yourself — typically with a
418    /// [`DialogFooter`](crate::dialog::DialogFooter) — or use an
419    /// [`AlertDialog`](crate::dialog::AlertDialog), which builds a default
420    /// footer from `button_props` when no footer is set.
421    pub fn footer(mut self, footer: impl IntoElement) -> Self {
422        self.footer = Some(footer.into_any_element());
423        self
424    }
425
426    /// Set the button props of the dialog.
427    ///
428    /// This overrides only the fields `button_props` sets; the rest of the
429    /// dialog's button configuration is kept, so the call order does not
430    /// matter.
431    ///
432    /// On a plain `Dialog` these props install the callbacks behind the
433    /// `Confirm` and `Cancel` actions and the labels an
434    /// [`AlertDialog`](crate::dialog::AlertDialog) default footer would use;
435    /// the dialog surface itself renders no buttons from them, see
436    /// [`Self::footer`].
437    pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
438        self.button_props.merge(button_props);
439        self
440    }
441
442    pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
443        self.base = Some(BaseDialogRoot::AlertDialog(base));
444        self.props.overlay_closable = false;
445        self
446    }
447
448    /// Sets the callback for when the dialog is closed.
449    ///
450    /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
451    pub fn on_close(
452        mut self,
453        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
454    ) -> Self {
455        self.button_props.on_close = Some(Rc::new(on_close));
456        self
457    }
458
459    /// Sets the callback for when the dialog is has been confirmed.
460    ///
461    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
462    pub fn on_ok(
463        mut self,
464        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
465    ) -> Self {
466        self.button_props = self.button_props.on_ok(on_ok);
467        self
468    }
469
470    /// Sets the callback for when the dialog is has been canceled.
471    ///
472    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
473    pub fn on_cancel(
474        mut self,
475        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
476    ) -> Self {
477        self.button_props = self.button_props.on_cancel(on_cancel);
478        self
479    }
480
481    /// Sets the false to hide close icon, default: true
482    pub fn close_button(mut self, close_button: bool) -> Self {
483        self.props.close_button = close_button;
484        self
485    }
486
487    /// Set the top offset of the dialog, defaults to None, will use the 1/10 of the viewport height.
488    pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
489        self.props.margin_top = Some(margin_top.into());
490        self
491    }
492
493    /// Centers the dialog vertically in the window, whatever its height, in
494    /// place of the [`Self::margin_top`] offset.
495    pub fn centered(mut self) -> Self {
496        self.props.centered = true;
497        self
498    }
499
500    /// Sets the width of the dialog, defaults to 448px.
501    ///
502    /// The dialog is never wider than the viewport minus a margin on each side.
503    ///
504    /// See also [`Self::width`]
505    pub fn w(mut self, width: impl Into<Pixels>) -> Self {
506        self.props.width = width.into();
507        self
508    }
509
510    /// Sets the width of the dialog, defaults to 448px.
511    ///
512    /// The dialog is never wider than the viewport minus a margin on each side.
513    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
514        self.props.width = width.into();
515        self
516    }
517
518    /// Set the maximum width of the dialog, defaults to `None`.
519    pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
520        self.props.max_width = Some(max_width.into());
521        self
522    }
523
524    /// Set the overlay of the dialog, defaults to `true`.
525    pub fn overlay(mut self, overlay: bool) -> Self {
526        self.props.overlay = overlay;
527        self
528    }
529
530    /// Set the overlay closable of the dialog, defaults to `true`.
531    ///
532    /// When the overlay is clicked, the dialog will be closed.
533    pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
534        self.props.overlay_closable = overlay_closable;
535        self
536    }
537
538    /// Set whether to support keyboard esc to close the dialog, defaults to `true`.
539    pub fn keyboard(mut self, keyboard: bool) -> Self {
540        self.props.keyboard = keyboard;
541        self
542    }
543
544    /// Sets how the dialog enters, defaulting to [`DialogEntrance::SlideDown`].
545    ///
546    /// This controls the surface and backdrop together, and closing animates
547    /// both back out. Reduced motion skips both and shows the final state
548    /// immediately, as does [`DialogEntrance::None`].
549    pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
550        self.props.entrance = entrance;
551        self
552    }
553
554    /// How long the exit transition runs; `Duration::ZERO` closes in one frame.
555    ///
556    /// Mirrors the entrance: [`DialogEntrance::None`], reduced motion, and a
557    /// zero theme duration leave nothing to animate out.
558    pub(crate) fn exit_duration(&self, cx: &App) -> Duration {
559        if cx.reduce_motion() {
560            return Duration::ZERO;
561        }
562
563        match self.props.entrance {
564            DialogEntrance::None => Duration::ZERO,
565            DialogEntrance::SlideDown => *ANIMATION_DURATION,
566            _ => cx.theme().motion_tokens().duration_normal,
567        }
568    }
569
570    pub(crate) fn motion_transition(&self, cx: &App) -> Transition {
571        let transition = Transition::new(self.exit_duration(cx));
572        // Use the same curve in both directions: changing the curve at the
573        // reversal would resample the entrance at a different position.
574        if self.props.entrance == DialogEntrance::SlideDown {
575            transition.ease(cubic_bezier(1. / 3., 0.72, 2. / 3., 1.))
576        } else {
577            let easing = cx.theme().motion_tokens().easing_enter.clone();
578            transition.ease(move |delta| easing.sample(delta).clamp(0., 1.))
579        }
580    }
581
582    pub(crate) fn has_overlay(&self) -> bool {
583        self.props.overlay
584    }
585
586    pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
587        self.props = props;
588        self
589    }
590
591    fn defer_close_dialog(window: &mut Window, cx: &mut App) {
592        WindowState::update(window, cx, |root, window, cx| {
593            root.defer_close_dialog(window, cx);
594        });
595    }
596}
597
598impl ParentElement for Dialog {
599    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
600        self.children.extend(elements);
601    }
602}
603
604impl Styled for Dialog {
605    fn style(&mut self) -> &mut gpui::StyleRefinement {
606        &mut self.style
607    }
608}
609
610impl Dialog {
611    fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
612        let content_builder = self.content_builder.clone();
613        let style = self.style.clone();
614        let props = self.props.clone();
615        let button_props = self.button_props.clone();
616
617        gpui_base::DialogTrigger::new(trigger)
618            .on_open(move |window, cx| {
619                let content_builder = content_builder.clone();
620                let style = style.clone();
621                let props = props.clone();
622                let button_props = button_props.clone();
623                window.open_dialog(cx, move |dialog, _, _| {
624                    dialog
625                        .refine_style(&style)
626                        .button_props(button_props.clone())
627                        .with_props(props.clone())
628                        .content({
629                            let content_builder = content_builder.clone();
630                            move |content, window, cx| {
631                                if let Some(builder) = content_builder.clone() {
632                                    builder(content, window, cx)
633                                } else {
634                                    content
635                                }
636                            }
637                        })
638                });
639            })
640            .into_any_element()
641    }
642}
643
644impl RenderOnce for Dialog {
645    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
646        if let Some(trigger) = self.trigger.take() {
647            return self.render_trigger(trigger, window, cx);
648        }
649
650        let layer_ix = self.layer_ix;
651        let selection_scope = self.selection_scope;
652        let on_close = self.button_props.close_handler();
653        let on_ok = self.button_props.ok_handler();
654        let on_cancel = self.button_props.cancel_handler();
655
656        let window_paddings = crate::window_border::window_paddings(window);
657        let view_size = window.viewport_size()
658            - gpui::size(
659                window_paddings.left + window_paddings.right,
660                window_paddings.top + window_paddings.bottom,
661            );
662        // The dialog keeps this much of the viewport clear around its edges,
663        // so a small window shrinks the surface instead of letting it run off
664        // the window. A dialog that fits keeps `margin_top` (a tenth of the
665        // viewport by default); an overflowing one is snapped up to this edge
666        // margin so that the preferred offset does not waste usable height.
667        let margin = cx.theme().spacing_tokens().lg;
668        let layer_offset = px(layer_ix as f32 * 16.);
669        let (anchor, y) = if self.props.centered {
670            (Anchor::LeftCenter, view_size.height / 2.)
671        } else {
672            let y = self.props.margin_top.unwrap_or(view_size.height / 10.);
673            (Anchor::TopLeft, y)
674        };
675        let y = y + layer_offset;
676        let entrance = self.props.entrance;
677        let motion = cx.theme().motion_tokens();
678        let closing = self.motion.is_some_and(|motion| motion.closing);
679        let visibility = self
680            .motion
681            .map_or(1.0, |motion| motion.visibility.clamp(0., 1.));
682        let fade_overlay = closing && self.props.overlay_visible;
683        let travel = motion.distance_short.to_pixels(window.rem_size());
684        let width = self
685            .props
686            .width
687            .min((view_size.width - margin * 2.).max(px(0.)));
688        let x = (view_size.width - width) / 2.;
689        let max_height = (view_size.height - margin * 2. - layer_offset).max(px(0.));
690
691        let base_size = window.text_style().font_size;
692        let rem_size = window.rem_size();
693
694        let mut paddings = Edges::all(px(16.));
695        if let Some(pl) = self.style.padding.left {
696            paddings.left = pl.to_pixels(base_size, rem_size);
697        }
698        if let Some(pr) = self.style.padding.right {
699            paddings.right = pr.to_pixels(base_size, rem_size);
700        }
701        if let Some(pt) = self.style.padding.top {
702            paddings.top = pt.to_pixels(base_size, rem_size);
703        }
704        if let Some(pb) = self.style.padding.bottom {
705            paddings.bottom = pb.to_pixels(base_size, rem_size);
706        }
707
708        // The newest dialog still open owns Escape, the backdrop, and the
709        // focus trap; a closing dialog keeps its layer only to finish leaving.
710        let topmost = WindowState::read(window, cx)
711            .active_dialogs
712            .iter()
713            .rposition(|dialog| !dialog.closing);
714        let is_topmost = !closing && topmost == Some(layer_ix);
715
716        anchored()
717            .position(point(window_paddings.left, window_paddings.top))
718            .snap_to_window()
719            .child(
720                div()
721                    .id("dialog")
722                    .test_support()
723                    // A closing dialog is leaving: it must not swallow input
724                    // aimed at what is already usable again beneath it.
725                    .when(!closing, |this| this.occlude())
726                    .w(view_size.width)
727                    .h(view_size.height)
728                    .when(fade_overlay, |this| {
729                        this.child(div().absolute().inset_0().bg(overlay_color(true, cx)))
730                    })
731                    .child(
732                        self.base
733                            .take()
734                            .expect("Dialog base host is always present")
735                            .layer(self.id, is_topmost)
736                            .focus_handle(self.focus_handle.clone())
737                            .close_on_escape(self.props.keyboard && !closing)
738                            .close_on_backdrop_press(self.props.overlay_closable && !closing)
739                            .dismiss_below_y(TITLE_BAR_HEIGHT)
740                            .when(self.props.overlay && !closing, |this| {
741                                this.backdrop(
742                                    div()
743                                        .absolute()
744                                        .size_full()
745                                        .window_control_area(WindowControlArea::Drag)
746                                        .when(self.props.overlay_visible, |overlay| {
747                                            overlay.bg(overlay_color(true, cx))
748                                        }),
749                                )
750                            })
751                            .on_ok(move |event, window, cx| !closing && on_ok(event, window, cx))
752                            .on_cancel(move |event, window, cx| {
753                                !closing && on_cancel(event, window, cx)
754                            })
755                            .on_close(move |event, window, cx| on_close(event, window, cx))
756                            .request_close(move |deferred, window, cx| {
757                                // A dialog already leaving must not close
758                                // whatever sits below it.
759                                if closing {
760                                    return;
761                                }
762                                if deferred {
763                                    Self::defer_close_dialog(window, cx);
764                                } else {
765                                    window.close_dialog(cx);
766                                }
767                            })
768                            .popup(
769                                gpui_base::Positioner::corner(
770                                    anchor,
771                                    point(window_paddings.left + x, window_paddings.top + y),
772                                )
773                                .margin(margin)
774                                .child(
775                                    v_flex()
776                                        .id("surface")
777                                        .test_support()
778                                        .debug_selector(move || format!("dialog-{layer_ix}"))
779                                        .bg(cx.theme().tokens.background)
780                                        .border_1()
781                                        .border_color(cx.theme().border)
782                                        .rounded(cx.theme().radius_lg)
783                                        .min_h_24()
784                                        .pt(paddings.top)
785                                        .pb(paddings.bottom)
786                                        .gap(paddings.top.max(px(8.)))
787                                        .refine_style(&self.style)
788                                        .px_0()
789                                        .occlude()
790                                        .w(width)
791                                        .when_some(self.props.max_width, |this, w| this.max_w(w))
792                                        .max_h(max_height)
793                                        .child(
794                                            v_flex()
795                                                .flex_1()
796                                                .overflow_hidden()
797                                                .gap_y_2()
798                                                .when_some(self.header, |this, header| {
799                                                    this.child(
800                                                        div()
801                                                            .pl(paddings.left)
802                                                            .pr(paddings.right)
803                                                            .child(header),
804                                                    )
805                                                })
806                                                .when_some(self.title, |this, title| {
807                                                    this.child(
808                                                        DialogTitle::new()
809                                                            .pl(paddings.left)
810                                                            .pr(paddings.right)
811                                                            .child(title),
812                                                    )
813                                                })
814                                                .when_some(self.content_builder, |this, builder| {
815                                                    this.child(builder(
816                                                        DialogContent::new()
817                                                            .gap(paddings.bottom)
818                                                            .pl(paddings.left)
819                                                            .pr(paddings.right),
820                                                        window,
821                                                        cx,
822                                                    ))
823                                                })
824                                                .when(!self.children.is_empty(), |this| {
825                                                    this.child(
826                                                        div().flex_1().overflow_hidden().child(
827                                                            // Body
828                                                            v_flex()
829                                                                .size_full()
830                                                                .overflow_y_scrollbar()
831                                                                .pl(paddings.left)
832                                                                .pr(paddings.right)
833                                                                .children(self.children),
834                                                        ),
835                                                    )
836                                                }),
837                                        )
838                                        .when_some(self.footer, |this, footer| {
839                                            this.child(
840                                                div()
841                                                    .pl(paddings.left)
842                                                    .pr(paddings.right)
843                                                    .child(footer),
844                                            )
845                                        })
846                                        .children(self.props.close_button.then(|| {
847                                            let top = (paddings.top - px(10.)).max(px(8.));
848                                            let right = (paddings.right - px(10.)).max(px(8.));
849
850                                            gpui_base::DialogClose::new()
851                                                .absolute()
852                                                .top(top)
853                                                .right(right)
854                                                .trigger(|button| {
855                                                    Button::new("close")
856                                                        .with_base(button)
857                                                        .small()
858                                                        .ghost()
859                                                        .icon(IconName::Close)
860                                                })
861                                        }))
862                                        .when(closing, |surface| {
863                                            // The shield is painted after the content, so
864                                            // descendant hitboxes cannot receive input.
865                                            surface
866                                                .capture_any_mouse_down(|_, _, cx| {
867                                                    cx.stop_propagation()
868                                                })
869                                                .capture_any_mouse_up(|_, _, cx| {
870                                                    cx.stop_propagation()
871                                                })
872                                                .capture_key_down(|_, window, cx| {
873                                                    window.prevent_default();
874                                                    cx.stop_propagation();
875                                                })
876                                                .capture_key_up(|_, _, cx| cx.stop_propagation())
877                                                .child(
878                                                    div()
879                                                        .absolute()
880                                                        .inset_0()
881                                                        .occlude()
882                                                        .on_any_mouse_down(|_, _, cx| {
883                                                            cx.stop_propagation()
884                                                        })
885                                                        .on_scroll(|_, _, cx| {
886                                                            cx.stop_propagation()
887                                                        }),
888                                                )
889                                        })
890                                        // Base paints its popup in a deferred pass; the
891                                        // surface must apply opacity itself.
892                                        .opacity(visibility)
893                                        .shadow(dialog_shadow(visibility))
894                                        .text_selection_scope(selection_scope)
895                                        .map(|surface| {
896                                            if entrance == DialogEntrance::FadeSlide {
897                                                let mut surface = EntranceSurface::new(
898                                                    surface.into_any_element(),
899                                                    travel,
900                                                    window_paddings.top + margin,
901                                                );
902                                                surface.progress = visibility;
903                                                surface.into_any_element()
904                                            } else {
905                                                surface.into_any_element()
906                                            }
907                                        }),
908                                )
909                                .when(
910                                    entrance == DialogEntrance::SlideDown,
911                                    |positioner| {
912                                        positioner.position(point(
913                                            window_paddings.left + x,
914                                            window_paddings.top + y * visibility,
915                                        ))
916                                    },
917                                ),
918                            ),
919                    )
920                    .opacity(visibility),
921            )
922            .into_any_element()
923    }
924}
925
926#[cfg(test)]
927pub(crate) mod tests {
928    use super::*;
929    use gpui::{
930        AppContext as _, Bounds, Context, Render, StatefulInteractiveElement as _, TestAppContext,
931        VisualTestContext, size,
932    };
933    use std::{cell::Cell, rc::Rc};
934
935    struct DialogHost {
936        clicks: Rc<Cell<usize>>,
937    }
938
939    impl Render for DialogHost {
940        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
941            let clicks = self.clicks.clone();
942            div().size_full().child(
943                // A target beneath the dialog's backdrop, so tests can observe
944                // whether presses reach the application while it is covered.
945                div()
946                    .id("background-target")
947                    .absolute()
948                    .top_0()
949                    .left_0()
950                    .w_full()
951                    .h(px(200.))
952                    .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
953            )
954        }
955    }
956
957    /// A window of `window_size` whose root renders the dialog layer, with
958    /// motion reduced so the entrance animation settles on its first frame.
959    pub(crate) fn window(
960        cx: &mut TestAppContext,
961        window_size: gpui::Size<Pixels>,
962    ) -> &mut VisualTestContext {
963        let (cx, _) = window_with_click_count(cx, window_size);
964        cx
965    }
966
967    /// [`window`], also returning how often the click target beneath the
968    /// dialog was pressed.
969    fn window_with_click_count(
970        cx: &mut TestAppContext,
971        window_size: gpui::Size<Pixels>,
972    ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
973        cx.update(|cx| {
974            crate::init(cx);
975            cx.set_reduce_motion(true);
976        });
977        let clicks = Rc::new(Cell::new(0));
978        let counter = clicks.clone();
979        let (_, cx) = cx.add_window_view(|window, cx| {
980            let view = cx.new(|_| DialogHost { clicks: counter });
981            crate::Root::new(view, window, cx)
982        });
983        cx.simulate_resize(window_size);
984        cx.update(|window, cx| window.draw(cx).clear(cx));
985        (cx, clicks)
986    }
987
988    fn open(
989        cx: &mut VisualTestContext,
990        build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
991    ) {
992        cx.update(|window, cx| window.open_dialog(cx, build));
993        cx.run_until_parked();
994        // Mount the layer and paint the current animation sample.
995        cx.update(|window, cx| window.draw(cx).clear(cx));
996        cx.update(|window, cx| window.draw(cx).clear(cx));
997    }
998
999    /// Draws one frame, so an exit transition samples the clock at its
1000    /// current value.
1001    fn frame(cx: &mut VisualTestContext) {
1002        cx.update(|window, _| window.refresh());
1003        cx.run_until_parked();
1004    }
1005
1006    fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
1007        let selector = ["dialog-0", "dialog-1"][layer_ix];
1008        cx.debug_bounds(selector)
1009            .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
1010    }
1011
1012    /// The clamp must not touch a dialog that already fits: the default width
1013    /// and the tenth-of-the-viewport top offset are the documented contract.
1014    #[gpui::test]
1015    fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
1016        let cx = window(cx, size(px(1000.), px(800.)));
1017        open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
1018
1019        let bounds = surface(cx, 0);
1020        assert_eq!(bounds.size.width, px(448.));
1021        assert_eq!(bounds.origin.x, px(276.));
1022        assert_eq!(bounds.origin.y, px(80.));
1023    }
1024
1025    /// A dialog wider and taller than the window must shrink to the viewport
1026    /// instead of running off both edges. It also gives up the usual top
1027    /// offset so that space is available to its content, while its footer
1028    /// remains inside the surface rather than clipped below it.
1029    #[gpui::test]
1030    fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
1031        let viewport = size(px(400.), px(300.));
1032        let cx = window(cx, viewport);
1033        open(cx, |dialog, _, _| {
1034            dialog
1035                .w(px(800.))
1036                .title("Too big")
1037                .child(div().h(px(1000.)).child("tall body"))
1038                .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
1039        });
1040
1041        let bounds = surface(cx, 0);
1042        let footer = cx.debug_bounds("footer-probe").unwrap();
1043        let margin = px(16.);
1044        assert!(
1045            bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
1046            "the dialog ran off the sides: {bounds:?}"
1047        );
1048        assert!(
1049            bounds.bottom() <= viewport.height - margin,
1050            "the dialog ran off the bottom: {bounds:?}"
1051        );
1052        assert_eq!(bounds.origin.y, margin);
1053        assert!(
1054            footer.bottom() <= bounds.bottom(),
1055            "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
1056        );
1057    }
1058
1059    /// `Dialog::button_props` overrides only the fields it sets.
1060    #[gpui::test]
1061    fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
1062        let cx = window(cx, size(px(400.), px(300.)));
1063        cx.update(|_, cx| {
1064            let dialog = Dialog::new(cx)
1065                .button_props(DialogButtonProps::default().cancel_text("Keep"))
1066                .button_props(DialogButtonProps::default().ok_text("Delete"))
1067                .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
1068
1069            assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
1070            assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
1071            assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
1072        });
1073    }
1074
1075    /// Each stacked dialog steps down 16px; the deepest one must still end
1076    /// above the bottom margin.
1077    #[gpui::test]
1078    fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
1079        let viewport = size(px(400.), px(300.));
1080        let cx = window(cx, viewport);
1081        open(cx, |dialog, _, _| {
1082            dialog.title("First").child(div().h(px(1000.)))
1083        });
1084        open(cx, |dialog, _, _| {
1085            dialog.title("Second").child(div().h(px(1000.)))
1086        });
1087
1088        let first = surface(cx, 0);
1089        let second = surface(cx, 1);
1090        assert_eq!(second.origin.y, first.origin.y + px(16.));
1091        assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
1092        assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
1093        assert!(second.size.height < first.size.height);
1094    }
1095
1096    #[gpui::test]
1097    fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
1098        cx.update(crate::init);
1099        cx.update(|cx| {
1100            assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
1101            for entrance in [
1102                DialogEntrance::SlideDown,
1103                DialogEntrance::Fade,
1104                DialogEntrance::FadeSlide,
1105                DialogEntrance::None,
1106            ] {
1107                assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
1108            }
1109        });
1110    }
1111
1112    #[gpui::test]
1113    fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
1114        for entrance in [
1115            DialogEntrance::SlideDown,
1116            DialogEntrance::Fade,
1117            DialogEntrance::FadeSlide,
1118            DialogEntrance::None,
1119        ] {
1120            let cx = window(cx, size(px(1000.), px(800.)));
1121            open(cx, move |dialog, _, _| {
1122                dialog
1123                    .title("Entrance")
1124                    .child("body")
1125                    .entrance(entrance)
1126                    .margin_top(px(300.))
1127            });
1128            assert_eq!(surface(cx, 0).top(), px(300.));
1129        }
1130    }
1131
1132    #[gpui::test]
1133    fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
1134        let cx = window(cx, size(px(1000.), px(800.)));
1135        cx.update(|_, cx| cx.set_reduce_motion(false));
1136        open(cx, |dialog, _, _| {
1137            dialog
1138                .title("Immediate")
1139                .child("body")
1140                .entrance(DialogEntrance::None)
1141                .margin_top(px(300.))
1142        });
1143        assert_eq!(surface(cx, 0).top(), px(300.));
1144        cx.simulate_keystrokes("escape");
1145        cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
1146    }
1147
1148    /// Closing keeps the dialog mounted until the entrance plays back out, and
1149    /// the dialog stops counting as active before it leaves the screen.
1150    #[gpui::test]
1151    fn closing_plays_the_entrance_backwards_before_the_dialog_unmounts(cx: &mut TestAppContext) {
1152        let cx = window(cx, size(px(1000.), px(800.)));
1153        cx.update(|_, cx| cx.set_reduce_motion(false));
1154        open(cx, |dialog, _, _| {
1155            dialog.title("Closing").child("body").margin_top(px(300.))
1156        });
1157
1158        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1159        frame(cx);
1160        cx.update(|window, cx| window.close_dialog(cx));
1161        frame(cx);
1162        // The exit starts from the resting position and the dialog is already
1163        // logically inactive, though it still paints.
1164        cx.update(|window, cx| {
1165            assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1);
1166            assert!(!window.has_active_dialog(cx));
1167        });
1168        assert_eq!(surface(cx, 0).top(), px(300.));
1169
1170        // Halfway through, the surface has slid up between rest and the top.
1171        cx.background_executor
1172            .advance_clock(*ANIMATION_DURATION / 2);
1173        frame(cx);
1174        cx.update(|window, cx| assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1));
1175        let halfway = surface(cx, 0).top();
1176        assert!(
1177            halfway > px(0.) && halfway < px(300.),
1178            "the closing dialog should slide back up: {halfway:?}"
1179        );
1180
1181        // Once the transition finishes the dialog leaves the window state.
1182        cx.background_executor
1183            .advance_clock(*ANIMATION_DURATION / 2 + Duration::from_millis(1));
1184        frame(cx);
1185        cx.update(|window, cx| {
1186            assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1187            assert!(!window.has_active_dialog(cx));
1188        });
1189        assert!(cx.debug_bounds("dialog-0").is_none());
1190    }
1191
1192    #[gpui::test]
1193    fn reduced_motion_closes_in_a_single_frame(cx: &mut TestAppContext) {
1194        let cx = window(cx, size(px(1000.), px(800.)));
1195        open(cx, |dialog, _, _| dialog.title("Reduced").child("body"));
1196        cx.update(|window, cx| window.close_dialog(cx));
1197        cx.update(|window, cx| {
1198            assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1199            assert!(!window.has_active_dialog(cx));
1200        });
1201    }
1202
1203    #[gpui::test]
1204    fn zero_theme_duration_closes_fade_entrances_immediately(cx: &mut TestAppContext) {
1205        for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1206            let cx = window(cx, size(px(1000.), px(800.)));
1207            cx.update(|_, cx| {
1208                cx.set_reduce_motion(false);
1209                crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1210            });
1211            open(cx, move |dialog, _, _| {
1212                dialog.title("Immediate").child("body").entrance(entrance)
1213            });
1214            cx.update(|window, cx| window.close_dialog(cx));
1215            cx.update(|window, cx| {
1216                assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1217                assert!(!window.has_active_dialog(cx));
1218            });
1219        }
1220    }
1221
1222    /// Opening a new dialog while the previous one is leaving keeps both
1223    /// mounted for the exit: the new dialog owns focus and survives it.
1224    #[gpui::test]
1225    fn reopening_during_the_exit_keeps_the_new_dialog(cx: &mut TestAppContext) {
1226        let cx = window(cx, size(px(1000.), px(800.)));
1227        cx.update(|_, cx| cx.set_reduce_motion(false));
1228        open(cx, |dialog, _, _| dialog.title("First").child("body"));
1229        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1230        frame(cx);
1231        cx.update(|window, cx| window.close_dialog(cx));
1232        frame(cx);
1233
1234        open(cx, |dialog, _, _| dialog.title("Second").child("body"));
1235        cx.update(|window, cx| {
1236            let state = WindowState::read(window, cx);
1237            assert_eq!(state.active_dialogs.len(), 2);
1238            assert!(state.active_dialogs[0].closing);
1239            assert!(!state.active_dialogs[1].closing);
1240            assert!(
1241                state.active_dialogs[1].focus_handle().is_focused(window),
1242                "the reopened dialog owns focus while the old one leaves"
1243            );
1244        });
1245
1246        cx.background_executor
1247            .advance_clock(*ANIMATION_DURATION + Duration::from_millis(1));
1248        frame(cx);
1249        // The old dialog is gone and the new one moves up to its layer.
1250        frame(cx);
1251        cx.update(|window, cx| {
1252            let state = WindowState::read(window, cx);
1253            assert_eq!(state.active_dialogs.len(), 1);
1254            assert!(!state.active_dialogs[0].closing);
1255            assert!(state.active_dialogs[0].focus_handle().is_focused(window));
1256            assert!(window.has_active_dialog(cx));
1257        });
1258        assert_eq!(surface(cx, 0).top(), px(80.));
1259        assert!(cx.debug_bounds("dialog-1").is_none());
1260    }
1261
1262    /// Closing the top of a stack only removes that dialog: the one below stays
1263    /// mounted and becomes the active dialog for the duration of the exit.
1264    #[gpui::test]
1265    fn closing_the_top_dialog_leaves_the_one_below_mounted(cx: &mut TestAppContext) {
1266        let cx = window(cx, size(px(1000.), px(800.)));
1267        cx.update(|_, cx| cx.set_reduce_motion(false));
1268        open(cx, |dialog, _, _| dialog.title("First").child("body"));
1269        open(cx, |dialog, _, _| dialog.title("Second").child("body"));
1270
1271        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1272        frame(cx);
1273        cx.update(|window, cx| window.close_dialog(cx));
1274        frame(cx);
1275        cx.update(|window, cx| {
1276            assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 2);
1277            assert!(window.has_active_dialog(cx));
1278        });
1279        assert!(
1280            cx.debug_bounds("dialog-0").is_some(),
1281            "the lower dialog stays"
1282        );
1283        assert!(
1284            cx.debug_bounds("dialog-1").is_some(),
1285            "the closing dialog still paints its exit"
1286        );
1287
1288        cx.background_executor
1289            .advance_clock(*ANIMATION_DURATION + Duration::from_millis(1));
1290        frame(cx);
1291        cx.update(|window, cx| {
1292            assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1);
1293            assert!(window.has_active_dialog(cx));
1294        });
1295        assert!(cx.debug_bounds("dialog-0").is_some());
1296        assert!(cx.debug_bounds("dialog-1").is_none());
1297    }
1298
1299    /// A leaving dialog releases input: a press that the open backdrop used to
1300    /// swallow now reaches the application beneath it.
1301    #[gpui::test]
1302    fn closing_dialog_does_not_block_input_beneath_it(cx: &mut TestAppContext) {
1303        let (cx, clicks) = window_with_click_count(cx, size(px(1000.), px(800.)));
1304        cx.update(|_, cx| cx.set_reduce_motion(false));
1305        open(cx, |dialog, _, _| {
1306            dialog
1307                .title("Closing")
1308                .child("body")
1309                .margin_top(px(300.))
1310                .overlay_closable(false)
1311        });
1312
1313        // The open dialog covers the window: the backdrop stops the press
1314        // before it can reach the target underneath.
1315        let backdrop_point = point(px(100.), px(60.));
1316        cx.simulate_click(backdrop_point, gpui::Modifiers::default());
1317        assert_eq!(clicks.get(), 0, "an open dialog blocked the application");
1318
1319        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1320        frame(cx);
1321        cx.update(|window, cx| window.close_dialog(cx));
1322        frame(cx);
1323        cx.simulate_click(backdrop_point, gpui::Modifiers::default());
1324        assert_eq!(
1325            clicks.get(),
1326            1,
1327            "the closing dialog swallowed a press meant for the application"
1328        );
1329    }
1330
1331    #[gpui::test]
1332    fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
1333        for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1334            let cx = window(cx, size(px(1000.), px(800.)));
1335            cx.update(|_, cx| {
1336                cx.set_reduce_motion(false);
1337                crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1338            });
1339            open(cx, move |dialog, _, _| {
1340                dialog
1341                    .title("Immediate")
1342                    .child("body")
1343                    .entrance(entrance)
1344                    .margin_top(px(300.))
1345            });
1346            assert_eq!(surface(cx, 0).top(), px(300.));
1347        }
1348    }
1349
1350    #[gpui::test]
1351    fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
1352        for entrance in [
1353            DialogEntrance::SlideDown,
1354            DialogEntrance::Fade,
1355            DialogEntrance::FadeSlide,
1356            DialogEntrance::None,
1357        ] {
1358            let cx = window(cx, size(px(1000.), px(800.)));
1359            cx.update(|window, cx| {
1360                let alert = crate::dialog::AlertDialog::new(cx)
1361                    .title("Alert")
1362                    .entrance(entrance);
1363                assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
1364                window.open_alert_dialog(cx, move |alert, _, _| {
1365                    alert.title("Alert").entrance(entrance)
1366                });
1367            });
1368            cx.run_until_parked();
1369            cx.update(|window, cx| window.draw(cx).clear(cx));
1370            cx.update(|window, cx| window.draw(cx).clear(cx));
1371            assert_eq!(surface(cx, 0).top(), px(80.));
1372        }
1373    }
1374
1375    /// Closing fades the surface itself, not only the layer: the base host
1376    /// paints in a deferred pass, where ancestor opacity no longer applies.
1377    #[gpui::test]
1378    fn closing_fades_the_dialog_surface(cx: &mut TestAppContext) {
1379        let cx = window(cx, size(px(1000.), px(800.)));
1380        cx.update(|_, cx| cx.set_reduce_motion(false));
1381        open(cx, |dialog, _, _| {
1382            dialog
1383                .title("Fading")
1384                .child("body")
1385                .margin_top(px(300.))
1386                .entrance(DialogEntrance::Fade)
1387        });
1388        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1389        frame(cx);
1390        cx.update(|window, cx| window.close_dialog(cx));
1391        frame(cx);
1392        assert_eq!(
1393            surface_alpha(cx),
1394            1.0,
1395            "the exit should start fully visible"
1396        );
1397
1398        cx.background_executor
1399            .advance_clock(Duration::from_millis(90));
1400        frame(cx);
1401        let alpha = surface_alpha(cx);
1402        assert!(
1403            alpha > 0.0 && alpha < 1.0,
1404            "the closing surface should fade, but its background is {alpha}"
1405        );
1406    }
1407
1408    #[gpui::test]
1409    fn closing_during_entrance_reverses_from_the_current_sample(cx: &mut TestAppContext) {
1410        for entrance in [
1411            DialogEntrance::SlideDown,
1412            DialogEntrance::Fade,
1413            DialogEntrance::FadeSlide,
1414        ] {
1415            let cx = window(cx, size(px(1000.), px(800.)));
1416            cx.update(|_, cx| cx.set_reduce_motion(false));
1417            open(cx, move |dialog, _, _| {
1418                dialog
1419                    .title("Interrupted")
1420                    .child("body")
1421                    .margin_top(px(300.))
1422                    .entrance(entrance)
1423            });
1424            cx.background_executor
1425                .advance_clock(Duration::from_millis(40));
1426            frame(cx);
1427            let before = surface(cx, 0);
1428            let alpha = surface_alpha(cx);
1429            assert!(alpha > 0. && alpha < 1.);
1430            cx.update(|window, cx| window.close_dialog(cx));
1431            frame(cx);
1432            assert_eq!(surface(cx, 0), before, "{entrance:?} jumped on close");
1433            assert!((surface_alpha(cx) - alpha).abs() < 0.001);
1434            cx.background_executor
1435                .advance_clock(Duration::from_millis(20));
1436            frame(cx);
1437            assert!(surface_alpha(cx) < alpha);
1438            assert!(surface(cx, 0).top() <= before.top());
1439        }
1440    }
1441
1442    #[gpui::test]
1443    fn exiting_content_rejects_clicks_focus_and_text_input(cx: &mut TestAppContext) {
1444        use crate::input::{Input, InputState};
1445        use gpui::Focusable as _;
1446
1447        for deferred in [false, true] {
1448            let cx = window(cx, size(px(1000.), px(800.)));
1449            let input = cx.update(|window, cx| cx.new(|cx| InputState::new(window, cx)));
1450            let clicks = Rc::new(Cell::new(0));
1451            let counter = clicks.clone();
1452            let field = input.clone();
1453            cx.update(|_, cx| cx.set_reduce_motion(false));
1454            open(cx, move |dialog, _, _| {
1455                let counter = counter.clone();
1456                dialog
1457                    .title("Form")
1458                    .child(
1459                        div()
1460                            .debug_selector(|| "exit-input".into())
1461                            .child(Input::new(&field)),
1462                    )
1463                    .footer(
1464                        div().debug_selector(|| "exit-button".into()).child(
1465                            Button::new("custom")
1466                                .label("Custom action")
1467                                .w_full()
1468                                .on_click(move |_, _, _| counter.set(counter.get() + 1)),
1469                        ),
1470                    )
1471            });
1472            cx.background_executor.advance_clock(*ANIMATION_DURATION);
1473            frame(cx);
1474            let button = cx.debug_bounds("exit-button").unwrap().center();
1475            cx.simulate_click(button, gpui::Modifiers::default());
1476            assert_eq!(clicks.get(), 1);
1477            cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1478            cx.simulate_input("before");
1479            assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1480            cx.update(|window, cx| {
1481                if deferred {
1482                    Dialog::defer_close_dialog(window, cx);
1483                } else {
1484                    window.close_dialog(cx);
1485                }
1486            });
1487            frame(cx);
1488            let button = cx.debug_bounds("exit-button").unwrap().center();
1489            cx.simulate_click(button, gpui::Modifiers::default());
1490            assert_eq!(clicks.get(), 1, "a closing footer button ran its callback");
1491            let field = cx.debug_bounds("exit-input").unwrap().center();
1492            cx.simulate_click(field, gpui::Modifiers::default());
1493            cx.simulate_input("after");
1494            assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1495            for key in ["tab", "shift-tab"] {
1496                cx.simulate_keystrokes(key);
1497                frame(cx);
1498                cx.update(|window, cx| assert!(!input.focus_handle(cx).is_focused(window)));
1499            }
1500            // Keyboard/a11y focus requests must not revive the exiting scope.
1501            cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1502            frame(cx);
1503            cx.update(|window, cx| assert!(!input.focus_handle(cx).is_focused(window)));
1504            cx.simulate_input("after");
1505            assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1506        }
1507    }
1508
1509    /// The most opaque background painted at the surface's current bounds.
1510    fn surface_alpha(cx: &mut VisualTestContext) -> f32 {
1511        let bounds = cx.debug_bounds("dialog-0").unwrap();
1512        let scale = cx.update(|window, _| window.scale_factor());
1513        let left = f32::from(bounds.origin.x) * scale;
1514        let top = f32::from(bounds.origin.y) * scale;
1515        let width = f32::from(bounds.size.width) * scale;
1516        let height = f32::from(bounds.size.height) * scale;
1517        cx.update(|window, _| {
1518            window
1519                .painted_quads()
1520                .into_iter()
1521                .filter(|quad| {
1522                    (quad.bounds.origin.x.0 - left).abs() < 1.0
1523                        && (quad.bounds.origin.y.0 - top).abs() < 1.0
1524                        && (quad.bounds.size.width.0 - width).abs() < 1.0
1525                        && (quad.bounds.size.height.0 - height).abs() < 1.0
1526                })
1527                .filter_map(|quad| quad.background.as_solid().map(|color| color.a))
1528                .fold(0.0_f32, f32::max)
1529        })
1530    }
1531}