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                                                        .accessibility_label("Close")
861                                                })
862                                        }))
863                                        .when(closing, |surface| {
864                                            // The shield is painted after the content, so
865                                            // descendant hitboxes cannot receive input.
866                                            surface
867                                                .capture_any_mouse_down(|_, _, cx| {
868                                                    cx.stop_propagation()
869                                                })
870                                                .capture_any_mouse_up(|_, _, cx| {
871                                                    cx.stop_propagation()
872                                                })
873                                                .capture_key_down(|_, window, cx| {
874                                                    window.prevent_default();
875                                                    cx.stop_propagation();
876                                                })
877                                                .capture_key_up(|_, _, cx| cx.stop_propagation())
878                                                .child(
879                                                    div()
880                                                        .absolute()
881                                                        .inset_0()
882                                                        .occlude()
883                                                        .on_any_mouse_down(|_, _, cx| {
884                                                            cx.stop_propagation()
885                                                        })
886                                                        .on_scroll(|_, _, cx| {
887                                                            cx.stop_propagation()
888                                                        }),
889                                                )
890                                        })
891                                        // Base paints its popup in a deferred pass; the
892                                        // surface must apply opacity itself.
893                                        .opacity(visibility)
894                                        .shadow(dialog_shadow(visibility))
895                                        .text_selection_scope(selection_scope)
896                                        .map(|surface| {
897                                            if entrance == DialogEntrance::FadeSlide {
898                                                let mut surface = EntranceSurface::new(
899                                                    surface.into_any_element(),
900                                                    travel,
901                                                    window_paddings.top + margin,
902                                                );
903                                                surface.progress = visibility;
904                                                surface.into_any_element()
905                                            } else {
906                                                surface.into_any_element()
907                                            }
908                                        }),
909                                )
910                                .when(
911                                    entrance == DialogEntrance::SlideDown,
912                                    |positioner| {
913                                        positioner.position(point(
914                                            window_paddings.left + x,
915                                            window_paddings.top + y * visibility,
916                                        ))
917                                    },
918                                ),
919                            ),
920                    )
921                    .opacity(visibility),
922            )
923            .into_any_element()
924    }
925}
926
927#[cfg(test)]
928pub(crate) mod tests {
929    use super::*;
930    use gpui::{
931        AppContext as _, Bounds, Context, Render, StatefulInteractiveElement as _, TestAppContext,
932        VisualTestContext, size,
933    };
934    use std::{cell::Cell, rc::Rc};
935
936    struct DialogHost {
937        clicks: Rc<Cell<usize>>,
938    }
939
940    impl Render for DialogHost {
941        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
942            let clicks = self.clicks.clone();
943            div().size_full().child(
944                // A target beneath the dialog's backdrop, so tests can observe
945                // whether presses reach the application while it is covered.
946                div()
947                    .id("background-target")
948                    .absolute()
949                    .top_0()
950                    .left_0()
951                    .w_full()
952                    .h(px(200.))
953                    .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
954            )
955        }
956    }
957
958    /// A window of `window_size` whose root renders the dialog layer, with
959    /// motion reduced so the entrance animation settles on its first frame.
960    pub(crate) fn window(
961        cx: &mut TestAppContext,
962        window_size: gpui::Size<Pixels>,
963    ) -> &mut VisualTestContext {
964        let (cx, _) = window_with_click_count(cx, window_size);
965        cx
966    }
967
968    /// [`window`], also returning how often the click target beneath the
969    /// dialog was pressed.
970    fn window_with_click_count(
971        cx: &mut TestAppContext,
972        window_size: gpui::Size<Pixels>,
973    ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
974        cx.update(|cx| {
975            crate::init(cx);
976            cx.set_reduce_motion(true);
977        });
978        let clicks = Rc::new(Cell::new(0));
979        let counter = clicks.clone();
980        let (_, cx) = cx.add_window_view(|window, cx| {
981            let view = cx.new(|_| DialogHost { clicks: counter });
982            crate::Root::new(view, window, cx)
983        });
984        cx.simulate_resize(window_size);
985        cx.update(|window, cx| window.draw(cx).clear(cx));
986        (cx, clicks)
987    }
988
989    fn open(
990        cx: &mut VisualTestContext,
991        build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
992    ) {
993        cx.update(|window, cx| window.open_dialog(cx, build));
994        cx.run_until_parked();
995        // Mount the layer and paint the current animation sample.
996        cx.update(|window, cx| window.draw(cx).clear(cx));
997        cx.update(|window, cx| window.draw(cx).clear(cx));
998    }
999
1000    /// Draws one frame, so an exit transition samples the clock at its
1001    /// current value.
1002    fn frame(cx: &mut VisualTestContext) {
1003        cx.update(|window, _| window.refresh());
1004        cx.run_until_parked();
1005    }
1006
1007    fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
1008        let selector = ["dialog-0", "dialog-1"][layer_ix];
1009        cx.debug_bounds(selector)
1010            .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
1011    }
1012
1013    /// The clamp must not touch a dialog that already fits: the default width
1014    /// and the tenth-of-the-viewport top offset are the documented contract.
1015    #[gpui::test]
1016    fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
1017        let cx = window(cx, size(px(1000.), px(800.)));
1018        open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
1019
1020        let bounds = surface(cx, 0);
1021        assert_eq!(bounds.size.width, px(448.));
1022        assert_eq!(bounds.origin.x, px(276.));
1023        assert_eq!(bounds.origin.y, px(80.));
1024    }
1025
1026    /// A dialog wider and taller than the window must shrink to the viewport
1027    /// instead of running off both edges. It also gives up the usual top
1028    /// offset so that space is available to its content, while its footer
1029    /// remains inside the surface rather than clipped below it.
1030    #[gpui::test]
1031    fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
1032        let viewport = size(px(400.), px(300.));
1033        let cx = window(cx, viewport);
1034        open(cx, |dialog, _, _| {
1035            dialog
1036                .w(px(800.))
1037                .title("Too big")
1038                .child(div().h(px(1000.)).child("tall body"))
1039                .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
1040        });
1041
1042        let bounds = surface(cx, 0);
1043        let footer = cx.debug_bounds("footer-probe").unwrap();
1044        let margin = px(16.);
1045        assert!(
1046            bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
1047            "the dialog ran off the sides: {bounds:?}"
1048        );
1049        assert!(
1050            bounds.bottom() <= viewport.height - margin,
1051            "the dialog ran off the bottom: {bounds:?}"
1052        );
1053        assert_eq!(bounds.origin.y, margin);
1054        assert!(
1055            footer.bottom() <= bounds.bottom(),
1056            "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
1057        );
1058    }
1059
1060    /// `Dialog::button_props` overrides only the fields it sets.
1061    #[gpui::test]
1062    fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
1063        let cx = window(cx, size(px(400.), px(300.)));
1064        cx.update(|_, cx| {
1065            let dialog = Dialog::new(cx)
1066                .button_props(DialogButtonProps::default().cancel_text("Keep"))
1067                .button_props(DialogButtonProps::default().ok_text("Delete"))
1068                .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
1069
1070            assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
1071            assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
1072            assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
1073        });
1074    }
1075
1076    /// Each stacked dialog steps down 16px; the deepest one must still end
1077    /// above the bottom margin.
1078    #[gpui::test]
1079    fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
1080        let viewport = size(px(400.), px(300.));
1081        let cx = window(cx, viewport);
1082        open(cx, |dialog, _, _| {
1083            dialog.title("First").child(div().h(px(1000.)))
1084        });
1085        open(cx, |dialog, _, _| {
1086            dialog.title("Second").child(div().h(px(1000.)))
1087        });
1088
1089        let first = surface(cx, 0);
1090        let second = surface(cx, 1);
1091        assert_eq!(second.origin.y, first.origin.y + px(16.));
1092        assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
1093        assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
1094        assert!(second.size.height < first.size.height);
1095    }
1096
1097    #[gpui::test]
1098    fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
1099        cx.update(crate::init);
1100        cx.update(|cx| {
1101            assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
1102            for entrance in [
1103                DialogEntrance::SlideDown,
1104                DialogEntrance::Fade,
1105                DialogEntrance::FadeSlide,
1106                DialogEntrance::None,
1107            ] {
1108                assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
1109            }
1110        });
1111    }
1112
1113    #[gpui::test]
1114    fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
1115        for entrance in [
1116            DialogEntrance::SlideDown,
1117            DialogEntrance::Fade,
1118            DialogEntrance::FadeSlide,
1119            DialogEntrance::None,
1120        ] {
1121            let cx = window(cx, size(px(1000.), px(800.)));
1122            open(cx, move |dialog, _, _| {
1123                dialog
1124                    .title("Entrance")
1125                    .child("body")
1126                    .entrance(entrance)
1127                    .margin_top(px(300.))
1128            });
1129            assert_eq!(surface(cx, 0).top(), px(300.));
1130        }
1131    }
1132
1133    #[gpui::test]
1134    fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
1135        let cx = window(cx, size(px(1000.), px(800.)));
1136        cx.update(|_, cx| cx.set_reduce_motion(false));
1137        open(cx, |dialog, _, _| {
1138            dialog
1139                .title("Immediate")
1140                .child("body")
1141                .entrance(DialogEntrance::None)
1142                .margin_top(px(300.))
1143        });
1144        assert_eq!(surface(cx, 0).top(), px(300.));
1145        cx.simulate_keystrokes("escape");
1146        cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
1147    }
1148
1149    /// Closing keeps the dialog mounted until the entrance plays back out, and
1150    /// the dialog stops counting as active before it leaves the screen.
1151    #[gpui::test]
1152    fn closing_plays_the_entrance_backwards_before_the_dialog_unmounts(cx: &mut TestAppContext) {
1153        let cx = window(cx, size(px(1000.), px(800.)));
1154        cx.update(|_, cx| cx.set_reduce_motion(false));
1155        open(cx, |dialog, _, _| {
1156            dialog.title("Closing").child("body").margin_top(px(300.))
1157        });
1158
1159        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1160        frame(cx);
1161        cx.update(|window, cx| window.close_dialog(cx));
1162        frame(cx);
1163        // The exit starts from the resting position and the dialog is already
1164        // logically inactive, though it still paints.
1165        cx.update(|window, cx| {
1166            assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1);
1167            assert!(!window.has_active_dialog(cx));
1168        });
1169        assert_eq!(surface(cx, 0).top(), px(300.));
1170
1171        // Halfway through, the surface has slid up between rest and the top.
1172        cx.background_executor
1173            .advance_clock(*ANIMATION_DURATION / 2);
1174        frame(cx);
1175        cx.update(|window, cx| assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1));
1176        let halfway = surface(cx, 0).top();
1177        assert!(
1178            halfway > px(0.) && halfway < px(300.),
1179            "the closing dialog should slide back up: {halfway:?}"
1180        );
1181
1182        // Once the transition finishes the dialog leaves the window state.
1183        cx.background_executor
1184            .advance_clock(*ANIMATION_DURATION / 2 + Duration::from_millis(1));
1185        frame(cx);
1186        cx.update(|window, cx| {
1187            assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1188            assert!(!window.has_active_dialog(cx));
1189        });
1190        assert!(cx.debug_bounds("dialog-0").is_none());
1191    }
1192
1193    #[gpui::test]
1194    fn reduced_motion_closes_in_a_single_frame(cx: &mut TestAppContext) {
1195        let cx = window(cx, size(px(1000.), px(800.)));
1196        open(cx, |dialog, _, _| dialog.title("Reduced").child("body"));
1197        cx.update(|window, cx| window.close_dialog(cx));
1198        cx.update(|window, cx| {
1199            assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1200            assert!(!window.has_active_dialog(cx));
1201        });
1202    }
1203
1204    #[gpui::test]
1205    fn zero_theme_duration_closes_fade_entrances_immediately(cx: &mut TestAppContext) {
1206        for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1207            let cx = window(cx, size(px(1000.), px(800.)));
1208            cx.update(|_, cx| {
1209                cx.set_reduce_motion(false);
1210                crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1211            });
1212            open(cx, move |dialog, _, _| {
1213                dialog.title("Immediate").child("body").entrance(entrance)
1214            });
1215            cx.update(|window, cx| window.close_dialog(cx));
1216            cx.update(|window, cx| {
1217                assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1218                assert!(!window.has_active_dialog(cx));
1219            });
1220        }
1221    }
1222
1223    /// Opening a new dialog while the previous one is leaving keeps both
1224    /// mounted for the exit: the new dialog owns focus and survives it.
1225    #[gpui::test]
1226    fn reopening_during_the_exit_keeps_the_new_dialog(cx: &mut TestAppContext) {
1227        let cx = window(cx, size(px(1000.), px(800.)));
1228        cx.update(|_, cx| cx.set_reduce_motion(false));
1229        open(cx, |dialog, _, _| dialog.title("First").child("body"));
1230        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1231        frame(cx);
1232        cx.update(|window, cx| window.close_dialog(cx));
1233        frame(cx);
1234
1235        open(cx, |dialog, _, _| dialog.title("Second").child("body"));
1236        cx.update(|window, cx| {
1237            let state = WindowState::read(window, cx);
1238            assert_eq!(state.active_dialogs.len(), 2);
1239            assert!(state.active_dialogs[0].closing);
1240            assert!(!state.active_dialogs[1].closing);
1241            assert!(
1242                state.active_dialogs[1].focus_handle().is_focused(window),
1243                "the reopened dialog owns focus while the old one leaves"
1244            );
1245        });
1246
1247        cx.background_executor
1248            .advance_clock(*ANIMATION_DURATION + Duration::from_millis(1));
1249        frame(cx);
1250        // The old dialog is gone and the new one moves up to its layer.
1251        frame(cx);
1252        cx.update(|window, cx| {
1253            let state = WindowState::read(window, cx);
1254            assert_eq!(state.active_dialogs.len(), 1);
1255            assert!(!state.active_dialogs[0].closing);
1256            assert!(state.active_dialogs[0].focus_handle().is_focused(window));
1257            assert!(window.has_active_dialog(cx));
1258        });
1259        assert_eq!(surface(cx, 0).top(), px(80.));
1260        assert!(cx.debug_bounds("dialog-1").is_none());
1261    }
1262
1263    /// Closing the top of a stack only removes that dialog: the one below stays
1264    /// mounted and becomes the active dialog for the duration of the exit.
1265    #[gpui::test]
1266    fn closing_the_top_dialog_leaves_the_one_below_mounted(cx: &mut TestAppContext) {
1267        let cx = window(cx, size(px(1000.), px(800.)));
1268        cx.update(|_, cx| cx.set_reduce_motion(false));
1269        open(cx, |dialog, _, _| dialog.title("First").child("body"));
1270        open(cx, |dialog, _, _| dialog.title("Second").child("body"));
1271
1272        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1273        frame(cx);
1274        cx.update(|window, cx| window.close_dialog(cx));
1275        frame(cx);
1276        cx.update(|window, cx| {
1277            assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 2);
1278            assert!(window.has_active_dialog(cx));
1279        });
1280        assert!(
1281            cx.debug_bounds("dialog-0").is_some(),
1282            "the lower dialog stays"
1283        );
1284        assert!(
1285            cx.debug_bounds("dialog-1").is_some(),
1286            "the closing dialog still paints its exit"
1287        );
1288
1289        cx.background_executor
1290            .advance_clock(*ANIMATION_DURATION + Duration::from_millis(1));
1291        frame(cx);
1292        cx.update(|window, cx| {
1293            assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1);
1294            assert!(window.has_active_dialog(cx));
1295        });
1296        assert!(cx.debug_bounds("dialog-0").is_some());
1297        assert!(cx.debug_bounds("dialog-1").is_none());
1298    }
1299
1300    /// A leaving dialog releases input: a press that the open backdrop used to
1301    /// swallow now reaches the application beneath it.
1302    #[gpui::test]
1303    fn closing_dialog_does_not_block_input_beneath_it(cx: &mut TestAppContext) {
1304        let (cx, clicks) = window_with_click_count(cx, size(px(1000.), px(800.)));
1305        cx.update(|_, cx| cx.set_reduce_motion(false));
1306        open(cx, |dialog, _, _| {
1307            dialog
1308                .title("Closing")
1309                .child("body")
1310                .margin_top(px(300.))
1311                .overlay_closable(false)
1312        });
1313
1314        // The open dialog covers the window: the backdrop stops the press
1315        // before it can reach the target underneath.
1316        let backdrop_point = point(px(100.), px(60.));
1317        cx.simulate_click(backdrop_point, gpui::Modifiers::default());
1318        assert_eq!(clicks.get(), 0, "an open dialog blocked the application");
1319
1320        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1321        frame(cx);
1322        cx.update(|window, cx| window.close_dialog(cx));
1323        frame(cx);
1324        cx.simulate_click(backdrop_point, gpui::Modifiers::default());
1325        assert_eq!(
1326            clicks.get(),
1327            1,
1328            "the closing dialog swallowed a press meant for the application"
1329        );
1330    }
1331
1332    #[gpui::test]
1333    fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
1334        for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1335            let cx = window(cx, size(px(1000.), px(800.)));
1336            cx.update(|_, cx| {
1337                cx.set_reduce_motion(false);
1338                crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1339            });
1340            open(cx, move |dialog, _, _| {
1341                dialog
1342                    .title("Immediate")
1343                    .child("body")
1344                    .entrance(entrance)
1345                    .margin_top(px(300.))
1346            });
1347            assert_eq!(surface(cx, 0).top(), px(300.));
1348        }
1349    }
1350
1351    #[gpui::test]
1352    fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
1353        for entrance in [
1354            DialogEntrance::SlideDown,
1355            DialogEntrance::Fade,
1356            DialogEntrance::FadeSlide,
1357            DialogEntrance::None,
1358        ] {
1359            let cx = window(cx, size(px(1000.), px(800.)));
1360            cx.update(|window, cx| {
1361                let alert = crate::dialog::AlertDialog::new(cx)
1362                    .title("Alert")
1363                    .entrance(entrance);
1364                assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
1365                window.open_alert_dialog(cx, move |alert, _, _| {
1366                    alert.title("Alert").entrance(entrance)
1367                });
1368            });
1369            cx.run_until_parked();
1370            cx.update(|window, cx| window.draw(cx).clear(cx));
1371            cx.update(|window, cx| window.draw(cx).clear(cx));
1372            assert_eq!(surface(cx, 0).top(), px(80.));
1373        }
1374    }
1375
1376    /// Closing fades the surface itself, not only the layer: the base host
1377    /// paints in a deferred pass, where ancestor opacity no longer applies.
1378    #[gpui::test]
1379    fn closing_fades_the_dialog_surface(cx: &mut TestAppContext) {
1380        let cx = window(cx, size(px(1000.), px(800.)));
1381        cx.update(|_, cx| cx.set_reduce_motion(false));
1382        open(cx, |dialog, _, _| {
1383            dialog
1384                .title("Fading")
1385                .child("body")
1386                .margin_top(px(300.))
1387                .entrance(DialogEntrance::Fade)
1388        });
1389        cx.background_executor.advance_clock(*ANIMATION_DURATION);
1390        frame(cx);
1391        cx.update(|window, cx| window.close_dialog(cx));
1392        frame(cx);
1393        assert_eq!(
1394            surface_alpha(cx),
1395            1.0,
1396            "the exit should start fully visible"
1397        );
1398
1399        cx.background_executor
1400            .advance_clock(Duration::from_millis(90));
1401        frame(cx);
1402        let alpha = surface_alpha(cx);
1403        assert!(
1404            alpha > 0.0 && alpha < 1.0,
1405            "the closing surface should fade, but its background is {alpha}"
1406        );
1407    }
1408
1409    #[gpui::test]
1410    fn closing_during_entrance_reverses_from_the_current_sample(cx: &mut TestAppContext) {
1411        for entrance in [
1412            DialogEntrance::SlideDown,
1413            DialogEntrance::Fade,
1414            DialogEntrance::FadeSlide,
1415        ] {
1416            let cx = window(cx, size(px(1000.), px(800.)));
1417            cx.update(|_, cx| cx.set_reduce_motion(false));
1418            open(cx, move |dialog, _, _| {
1419                dialog
1420                    .title("Interrupted")
1421                    .child("body")
1422                    .margin_top(px(300.))
1423                    .entrance(entrance)
1424            });
1425            cx.background_executor
1426                .advance_clock(Duration::from_millis(40));
1427            frame(cx);
1428            let before = surface(cx, 0);
1429            let alpha = surface_alpha(cx);
1430            assert!(alpha > 0. && alpha < 1.);
1431            cx.update(|window, cx| window.close_dialog(cx));
1432            frame(cx);
1433            assert_eq!(surface(cx, 0), before, "{entrance:?} jumped on close");
1434            assert!((surface_alpha(cx) - alpha).abs() < 0.001);
1435            cx.background_executor
1436                .advance_clock(Duration::from_millis(20));
1437            frame(cx);
1438            assert!(surface_alpha(cx) < alpha);
1439            assert!(surface(cx, 0).top() <= before.top());
1440        }
1441    }
1442
1443    #[gpui::test]
1444    fn exiting_content_rejects_clicks_focus_and_text_input(cx: &mut TestAppContext) {
1445        use crate::input::{Input, InputState};
1446        use gpui::Focusable as _;
1447
1448        for deferred in [false, true] {
1449            let cx = window(cx, size(px(1000.), px(800.)));
1450            let input = cx.update(|window, cx| cx.new(|cx| InputState::new(window, cx)));
1451            let clicks = Rc::new(Cell::new(0));
1452            let counter = clicks.clone();
1453            let field = input.clone();
1454            cx.update(|_, cx| cx.set_reduce_motion(false));
1455            open(cx, move |dialog, _, _| {
1456                let counter = counter.clone();
1457                dialog
1458                    .title("Form")
1459                    .child(
1460                        div()
1461                            .debug_selector(|| "exit-input".into())
1462                            .child(Input::new(&field)),
1463                    )
1464                    .footer(
1465                        div().debug_selector(|| "exit-button".into()).child(
1466                            Button::new("custom")
1467                                .label("Custom action")
1468                                .w_full()
1469                                .on_click(move |_, _, _| counter.set(counter.get() + 1)),
1470                        ),
1471                    )
1472            });
1473            cx.background_executor.advance_clock(*ANIMATION_DURATION);
1474            frame(cx);
1475            let button = cx.debug_bounds("exit-button").unwrap().center();
1476            cx.simulate_click(button, gpui::Modifiers::default());
1477            assert_eq!(clicks.get(), 1);
1478            cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1479            cx.simulate_input("before");
1480            assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1481            cx.update(|window, cx| {
1482                if deferred {
1483                    Dialog::defer_close_dialog(window, cx);
1484                } else {
1485                    window.close_dialog(cx);
1486                }
1487            });
1488            frame(cx);
1489            let button = cx.debug_bounds("exit-button").unwrap().center();
1490            cx.simulate_click(button, gpui::Modifiers::default());
1491            assert_eq!(clicks.get(), 1, "a closing footer button ran its callback");
1492            let field = cx.debug_bounds("exit-input").unwrap().center();
1493            cx.simulate_click(field, gpui::Modifiers::default());
1494            cx.simulate_input("after");
1495            assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1496            for key in ["tab", "shift-tab"] {
1497                cx.simulate_keystrokes(key);
1498                frame(cx);
1499                cx.update(|window, cx| assert!(!input.focus_handle(cx).is_focused(window)));
1500            }
1501            // Keyboard/a11y focus requests must not revive the exiting scope.
1502            cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1503            frame(cx);
1504            cx.update(|window, cx| assert!(!input.focus_handle(cx).is_focused(window)));
1505            cx.simulate_input("after");
1506            assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1507        }
1508    }
1509
1510    /// The most opaque background painted at the surface's current bounds.
1511    fn surface_alpha(cx: &mut VisualTestContext) -> f32 {
1512        let bounds = cx.debug_bounds("dialog-0").unwrap();
1513        let scale = cx.update(|window, _| window.scale_factor());
1514        let left = f32::from(bounds.origin.x) * scale;
1515        let top = f32::from(bounds.origin.y) * scale;
1516        let width = f32::from(bounds.size.width) * scale;
1517        let height = f32::from(bounds.size.height) * scale;
1518        cx.update(|window, _| {
1519            window
1520                .painted_quads()
1521                .into_iter()
1522                .filter(|quad| {
1523                    (quad.bounds.origin.x.0 - left).abs() < 1.0
1524                        && (quad.bounds.origin.y.0 - top).abs() < 1.0
1525                        && (quad.bounds.size.width.0 - width).abs() < 1.0
1526                        && (quad.bounds.size.height.0 - height).abs() < 1.0
1527                })
1528                .filter_map(|quad| quad.background.as_solid().map(|color| color.a))
1529                .fold(0.0_f32, f32::max)
1530        })
1531    }
1532}