Skip to main content

gpui_base/
dialog.rs

1use crate::TestSupportExt as _;
2use std::{
3    cell::{Cell, RefCell},
4    rc::Rc,
5};
6
7use gpui::{
8    AnyElement, App, ClickEvent, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding,
9    MouseButton, ParentElement, Pixels, RenderOnce, Role, SharedString,
10    StatefulInteractiveElement as _, StyleRefinement, Styled, Window, anchored, deferred, div,
11    point, prelude::FluentBuilder as _, px,
12};
13use smallvec::SmallVec;
14
15use crate::actions::{Cancel, Confirm};
16use crate::{FocusTrapElement as _, StyledExt as _};
17
18const CONTEXT: &str = "Dialog";
19type Decision = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
20type Closed = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
21type CloseRequest = Rc<dyn Fn(bool, &mut Window, &mut App)>;
22type OpenRequest = Rc<dyn Fn(&mut Window, &mut App)>;
23type OpenChange = Rc<dyn Fn(bool, DialogChangeReason, &mut Window, &mut App)>;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum DialogChangeReason {
27    TriggerPress,
28    BackdropPress,
29    Cancel,
30    Confirm,
31    Imperative,
32}
33
34#[derive(Clone)]
35pub struct DialogHandle {
36    open: Rc<Cell<bool>>,
37    on_open_change: Rc<RefCell<Option<OpenChange>>>,
38}
39
40impl DialogHandle {
41    pub fn new(open: bool) -> Self {
42        Self {
43            open: Rc::new(Cell::new(open)),
44            on_open_change: Rc::new(RefCell::new(None)),
45        }
46    }
47    pub fn is_open(&self) -> bool {
48        self.open.get()
49    }
50    pub fn open(&self, window: &mut Window, cx: &mut App) {
51        self.set_open(true, DialogChangeReason::Imperative, window, cx);
52    }
53    pub fn close(&self, window: &mut Window, cx: &mut App) {
54        self.set_open(false, DialogChangeReason::Imperative, window, cx);
55    }
56    pub(crate) fn set_open(
57        &self,
58        open: bool,
59        reason: DialogChangeReason,
60        window: &mut Window,
61        cx: &mut App,
62    ) {
63        if self.open.replace(open) == open {
64            return;
65        }
66        let callback = self.on_open_change.borrow().clone();
67        if let Some(callback) = callback {
68            callback(open, reason, window, cx);
69        }
70        window.refresh();
71    }
72}
73
74fn request_open_change(
75    handle: &Option<DialogHandle>,
76    callback: &Option<OpenChange>,
77    open: bool,
78    reason: DialogChangeReason,
79    window: &mut Window,
80    cx: &mut App,
81) {
82    if let Some(handle) = handle {
83        handle.set_open(open, reason, window, cx);
84    } else if let Some(callback) = callback {
85        callback(open, reason, window, cx);
86    }
87}
88
89pub fn init(cx: &mut App) {
90    cx.bind_keys([
91        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
92        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
93    ]);
94}
95
96impl Dialog {
97    pub fn on_ok(
98        mut self,
99        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
100    ) -> Self {
101        self.on_ok = Rc::new(handler);
102        self
103    }
104
105    pub fn on_cancel(
106        mut self,
107        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
108    ) -> Self {
109        self.on_cancel = Rc::new(handler);
110        self
111    }
112
113    pub fn on_close(
114        mut self,
115        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
116    ) -> Self {
117        self.on_close = Rc::new(handler);
118        self
119    }
120}
121
122/// Unstyled modal host owning focus, keyboard actions, dismissal, and callback ordering.
123#[derive(IntoElement)]
124pub struct Dialog {
125    style: StyleRefinement,
126    focus: FocusHandle,
127    role: Role,
128    accessibility_label: Option<SharedString>,
129    layer: usize,
130    keyboard: bool,
131    overlay_closable: bool,
132    topmost: bool,
133    dismiss_below_y: Pixels,
134    backdrop: Option<AnyElement>,
135    popup: Option<AnyElement>,
136    children: SmallVec<[AnyElement; 2]>,
137    on_ok: Decision,
138    on_cancel: Decision,
139    on_close: Closed,
140    request_close: CloseRequest,
141    handle: Option<DialogHandle>,
142    open: bool,
143    on_open_change: Option<OpenChange>,
144}
145
146/// Unstyled trigger that owns pointer activation for opening a dialog.
147#[derive(IntoElement)]
148pub struct DialogTrigger {
149    trigger: AnyElement,
150    open: OpenRequest,
151    handle: Option<DialogHandle>,
152}
153
154impl DialogTrigger {
155    pub fn new(trigger: impl IntoElement) -> Self {
156        Self {
157            trigger: trigger.into_any_element(),
158            open: Rc::new(|_, _| {}),
159            handle: None,
160        }
161    }
162    pub fn handle(mut self, handle: DialogHandle) -> Self {
163        self.handle = Some(handle);
164        self
165    }
166
167    pub fn on_open(mut self, open: impl Fn(&mut Window, &mut App) + 'static) -> Self {
168        self.open = Rc::new(open);
169        self
170    }
171}
172
173impl RenderOnce for DialogTrigger {
174    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
175        div()
176            .on_mouse_down(MouseButton::Left, move |_, window, cx| {
177                if let Some(handle) = self.handle.as_ref() {
178                    handle.set_open(true, DialogChangeReason::TriggerPress, window, cx);
179                }
180                (self.open)(window, cx);
181                cx.stop_propagation();
182            })
183            .child(self.trigger)
184    }
185}
186
187macro_rules! dialog_part {
188    ($(#[$meta:meta])* $name:ident, $id:literal) => {
189        dialog_part!($(#[$meta])* $name, $id, false);
190    };
191    ($(#[$meta:meta])* $name:ident, $id:literal, $occlude:literal) => {
192        $(#[$meta])*
193        #[derive(IntoElement)]
194        pub struct $name {
195            style: StyleRefinement,
196            children: SmallVec<[AnyElement; 2]>,
197        }
198
199        impl $name {
200            pub fn new() -> Self {
201                Self {
202                    style: StyleRefinement::default(),
203                    children: SmallVec::new(),
204                }
205            }
206        }
207
208        impl Default for $name {
209            fn default() -> Self {
210                Self::new()
211            }
212        }
213
214        impl Styled for $name {
215            fn style(&mut self) -> &mut StyleRefinement {
216                &mut self.style
217            }
218        }
219
220        impl ParentElement for $name {
221            fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
222                self.children.extend(elements);
223            }
224        }
225
226        impl RenderOnce for $name {
227            fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
228                div()
229                    .id($id)
230                    // The popup sits over the backdrop, whose press dismisses
231                    // the dialog, so it keeps presses on itself from falling through.
232                    .when($occlude, |this| this.occlude())
233                    .children(self.children)
234                    .refine_style(&self.style)
235            }
236        }
237    };
238}
239
240dialog_part!(
241    /// Unstyled backdrop part rendered behind a dialog popup.
242    DialogBackdrop,
243    "dialog-backdrop"
244);
245
246dialog_part!(
247    /// Unstyled popup part containing dialog content.
248    DialogPopup,
249    "dialog-popup",
250    true
251);
252
253/// Unstyled title slot for a dialog surface.
254#[derive(IntoElement)]
255pub struct DialogTitle {
256    base: gpui::Div,
257    style: StyleRefinement,
258    children: SmallVec<[AnyElement; 2]>,
259}
260
261impl DialogTitle {
262    pub fn new() -> Self {
263        Self {
264            base: div(),
265            style: StyleRefinement::default(),
266            children: SmallVec::new(),
267        }
268    }
269}
270
271impl Default for DialogTitle {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276impl Styled for DialogTitle {
277    fn style(&mut self) -> &mut StyleRefinement {
278        &mut self.style
279    }
280}
281impl ParentElement for DialogTitle {
282    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
283        self.children.extend(elements);
284    }
285}
286impl RenderOnce for DialogTitle {
287    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
288        self.base
289            .id("dialog-title")
290            .children(self.children)
291            .refine_style(&self.style)
292    }
293}
294
295/// Unstyled descriptive-content slot for a dialog surface.
296#[derive(IntoElement)]
297pub struct DialogDescription {
298    base: gpui::Div,
299    style: StyleRefinement,
300    children: SmallVec<[AnyElement; 2]>,
301}
302
303impl DialogDescription {
304    pub fn new() -> Self {
305        Self {
306            base: div(),
307            style: StyleRefinement::default(),
308            children: SmallVec::new(),
309        }
310    }
311}
312
313impl Default for DialogDescription {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318impl Styled for DialogDescription {
319    fn style(&mut self) -> &mut StyleRefinement {
320        &mut self.style
321    }
322}
323impl ParentElement for DialogDescription {
324    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
325        self.children.extend(elements);
326    }
327}
328impl RenderOnce for DialogDescription {
329    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
330        self.base
331            .id("dialog-description")
332            .children(self.children)
333            .refine_style(&self.style)
334    }
335}
336
337/// Wrapper that dispatches the dialog cancel action when activated.
338#[derive(IntoElement)]
339pub struct DialogClose {
340    style: StyleRefinement,
341    children: SmallVec<[AnyElement; 1]>,
342    trigger: Option<AnyElement>,
343    /// The focus node activation dispatches from, filled at render and read
344    /// when clicked, so the trigger built earlier reaches it too.
345    anchor: Rc<RefCell<Option<FocusHandle>>>,
346}
347
348impl DialogClose {
349    pub fn new() -> Self {
350        Self {
351            style: StyleRefinement::default(),
352            children: SmallVec::new(),
353            trigger: None,
354            anchor: Rc::default(),
355        }
356    }
357
358    /// Styles a button with the accessible name "Close" and cancel activation.
359    ///
360    /// The supplied button already supports pointer, keyboard, and accessibility
361    /// activation. The builder only needs to supply presentation.
362    /// The wrapper does not also handle clicks when a trigger is supplied.
363    pub fn trigger<E: IntoElement>(mut self, build: impl FnOnce(crate::Button) -> E) -> Self {
364        let anchor = self.anchor.clone();
365        let button = crate::Button::new("close")
366            .accessibility_label("Close")
367            .on_click(move |_, window, cx| Self::activate(&anchor, window, cx));
368        self.trigger = Some(build(button).into_any_element());
369        self
370    }
371
372    /// Dispatches [`Cancel`] from the control's own focus node, so it reaches
373    /// the dialog the control sits in whatever holds focus at that moment: a
374    /// surface that keeps taking focus back (a native web view, an
375    /// always-on-top window) would otherwise leave the control inert.
376    fn activate(anchor: &RefCell<Option<FocusHandle>>, window: &mut Window, cx: &mut App) {
377        // Clone out before dispatching so the dialog's handlers never run
378        // while the cell is borrowed.
379        let anchor = anchor.borrow().clone();
380        match anchor {
381            Some(anchor) => anchor.dispatch_action(&Cancel, window, cx),
382            None => window.dispatch_action(Box::new(Cancel), cx),
383        }
384    }
385}
386impl Default for DialogClose {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391impl ParentElement for DialogClose {
392    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
393        self.children.extend(elements);
394    }
395}
396impl Styled for DialogClose {
397    fn style(&mut self) -> &mut StyleRefinement {
398        &mut self.style
399    }
400}
401impl RenderOnce for DialogClose {
402    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
403        let anchor = window
404            .use_keyed_state("dialog-close-anchor", cx, |_, cx| cx.focus_handle())
405            .read(cx)
406            .clone();
407        *self.anchor.borrow_mut() = Some(anchor.clone());
408        let cell = self.anchor;
409        div()
410            .id("dialog-close")
411            // A zero-size, out-of-flow node that tracks the anchor: it is never
412            // hovered, so it takes no focus on its own and does not enter the
413            // Tab order, but it sits inside this dialog's dispatch path.
414            .child(div().absolute().size_0().track_focus(&anchor))
415            .when(self.trigger.is_none(), |this| {
416                this.on_click(move |_, window, cx| Self::activate(&cell, window, cx))
417            })
418            .children(self.trigger)
419            .children(self.children)
420            .refine_style(&self.style)
421    }
422}
423
424impl Dialog {
425    pub fn new(cx: &mut App) -> Self {
426        Self {
427            style: StyleRefinement::default(),
428            focus: cx.focus_handle(),
429            role: Role::Dialog,
430            accessibility_label: None,
431            layer: 0,
432            keyboard: true,
433            overlay_closable: true,
434            topmost: true,
435            dismiss_below_y: px(0.),
436            backdrop: None,
437            popup: None,
438            children: SmallVec::new(),
439            on_ok: Rc::new(|_, _, _| true),
440            on_cancel: Rc::new(|_, _, _| true),
441            on_close: Rc::new(|_, _, _| {}),
442            request_close: Rc::new(|_, _, _| {}),
443            handle: None,
444            open: true,
445            on_open_change: None,
446        }
447    }
448    pub fn open(mut self, open: bool) -> Self {
449        self.open = open;
450        self
451    }
452    pub fn handle(mut self, handle: DialogHandle) -> Self {
453        if let Some(callback) = self.on_open_change.as_ref() {
454            *handle.on_open_change.borrow_mut() = Some(callback.clone());
455        }
456        self.handle = Some(handle);
457        self
458    }
459    pub fn on_open_change(
460        mut self,
461        handler: impl Fn(bool, DialogChangeReason, &mut Window, &mut App) + 'static,
462    ) -> Self {
463        let handler: OpenChange = Rc::new(handler);
464        if let Some(handle) = self.handle.as_ref() {
465            *handle.on_open_change.borrow_mut() = Some(handler.clone());
466        }
467        self.on_open_change = Some(handler);
468        self
469    }
470
471    pub fn backdrop(mut self, element: impl IntoElement) -> Self {
472        self.backdrop = Some(element.into_any_element());
473        self
474    }
475    pub fn popup(mut self, element: impl IntoElement) -> Self {
476        self.popup = Some(element.into_any_element());
477        self
478    }
479    pub fn close_on_escape(mut self, value: bool) -> Self {
480        self.keyboard = value;
481        self
482    }
483    pub fn close_on_backdrop_press(mut self, value: bool) -> Self {
484        self.overlay_closable = value;
485        self
486    }
487    pub fn dismiss_below_y(mut self, value: Pixels) -> Self {
488        self.dismiss_below_y = value;
489        self
490    }
491    pub(crate) fn role(mut self, role: Role) -> Self {
492        self.role = role;
493        self
494    }
495    /// Names the dialog for accessibility clients (its title, typically).
496    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
497        self.accessibility_label = Some(label.into());
498        self
499    }
500    #[doc(hidden)]
501    pub fn layer(mut self, index: usize, topmost: bool) -> Self {
502        self.layer = index;
503        self.topmost = topmost;
504        self
505    }
506    #[doc(hidden)]
507    pub fn focus_handle(mut self, value: FocusHandle) -> Self {
508        self.focus = value;
509        self
510    }
511    #[doc(hidden)]
512    pub fn request_close(
513        mut self,
514        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
515    ) -> Self {
516        self.request_close = Rc::new(handler);
517        self
518    }
519}
520
521impl Styled for Dialog {
522    fn style(&mut self) -> &mut StyleRefinement {
523        &mut self.style
524    }
525}
526impl ParentElement for Dialog {
527    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
528        self.children.extend(elements);
529    }
530}
531
532impl RenderOnce for Dialog {
533    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
534        let open = self
535            .handle
536            .as_ref()
537            .map_or(self.open, DialogHandle::is_open);
538        if !open {
539            return div().into_any_element();
540        }
541        let request_close = self.request_close;
542        let cancel = self.on_cancel.clone();
543        let confirm = self.on_ok.clone();
544        let closed = self.on_close.clone();
545        let overlay_closable = self.overlay_closable && self.topmost;
546        let dismiss_below_y = self.dismiss_below_y;
547        let escape_handle = self.handle.clone();
548        let confirm_handle = self.handle.clone();
549        let backdrop_handle = self.handle.clone();
550        let escape_change = self.on_open_change.clone();
551        let confirm_change = self.on_open_change.clone();
552        let backdrop_change = self.on_open_change.clone();
553        let viewport = window.viewport_size();
554
555        deferred(
556            anchored().position(point(px(0.), px(0.))).child(
557                div()
558                    .id(("dialog-host", self.layer))
559                    .test_support()
560                    .absolute()
561                    .top_0()
562                    .left_0()
563                    .w(viewport.width)
564                    .h(viewport.height)
565                    // A popup in normal flow lands centered; `refine_style`
566                    // below lets the caller lay the host out differently.
567                    .flex()
568                    .items_center()
569                    .justify_center()
570                    .role(self.role)
571                    .when_some(self.accessibility_label.clone(), |this, label| {
572                        this.aria_label(label)
573                    })
574                    .track_focus(&self.focus)
575                    .focus_trap(format!("dialog-{}", self.layer), &self.focus)
576                    .when(self.keyboard, |this| this.key_context(CONTEXT))
577                    .map(|this| {
578                        let request_cancel = request_close.clone();
579                        let request_confirm = request_close.clone();
580                        let closed_cancel = closed.clone();
581                        this.on_action(move |_: &Cancel, window, cx| {
582                            let event = ClickEvent::default();
583                            if cancel(&event, window, cx) {
584                                request_open_change(
585                                    &escape_handle,
586                                    &escape_change,
587                                    false,
588                                    DialogChangeReason::Cancel,
589                                    window,
590                                    cx,
591                                );
592                                request_cancel(false, window, cx);
593                                closed_cancel(&event, window, cx);
594                            }
595                        })
596                        .on_action(move |_: &Confirm, window, cx| {
597                            let event = ClickEvent::default();
598                            if confirm(&event, window, cx) {
599                                request_open_change(
600                                    &confirm_handle,
601                                    &confirm_change,
602                                    false,
603                                    DialogChangeReason::Confirm,
604                                    window,
605                                    cx,
606                                );
607                                request_confirm(true, window, cx);
608                                closed(&event, window, cx);
609                            }
610                        })
611                    })
612                    .when_some(self.backdrop, |this, backdrop| {
613                        let cancel = self.on_cancel.clone();
614                        let closed = self.on_close.clone();
615                        let request_close = request_close.clone();
616                        this.child(
617                            div()
618                                // The backdrop covers the host, so a caller's
619                                // `absolute()` surface has a box to fill.
620                                .absolute()
621                                .inset_0()
622                                .on_any_mouse_down(move |event, window, cx| {
623                                    if event.position.y < dismiss_below_y {
624                                        return;
625                                    }
626                                    let button = event.button;
627                                    cx.stop_propagation();
628                                    let event = ClickEvent::default();
629                                    if button == MouseButton::Left
630                                        && overlay_closable
631                                        && cancel(&event, window, cx)
632                                    {
633                                        request_open_change(
634                                            &backdrop_handle,
635                                            &backdrop_change,
636                                            false,
637                                            DialogChangeReason::BackdropPress,
638                                            window,
639                                            cx,
640                                        );
641                                        request_close(false, window, cx);
642                                        closed(&event, window, cx);
643                                    }
644                                })
645                                .child(backdrop),
646                        )
647                    })
648                    .children(self.popup)
649                    .children(self.children)
650                    .refine_style(&self.style),
651            ),
652        )
653        .with_priority(10 + self.layer)
654        .into_any_element()
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use gpui::{Context, Render, point};
662    use std::{cell::RefCell, rc::Rc};
663
664    #[gpui::test]
665    fn a_dialog_takes_an_accessible_name(cx: &mut gpui::TestAppContext) {
666        struct Named;
667        impl Render for Named {
668            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
669                div().child(
670                    Dialog::new(cx)
671                        .accessibility_label("Discard draft?")
672                        .popup(div().size(px(10.))),
673                )
674            }
675        }
676        cx.update(crate::init);
677        let (_, cx) = cx.add_window_view(|_, _| Named);
678        cx.update(|window, cx| window.draw(cx).clear(cx));
679        let dialog = cx.update(|window, _| {
680            crate::test_support::snapshots(window)
681                .into_iter()
682                .find(|node| node.role() == Some(Role::Dialog))
683                .expect("a dialog node")
684        });
685        assert_eq!(dialog.label(), Some("Discard draft?"));
686    }
687
688    #[gpui::test]
689    fn close_trigger_supplies_accessible_button(cx: &mut gpui::TestAppContext) {
690        use gpui::{Element as _, accesskit, canvas};
691        use std::sync::{Arc, Mutex};
692
693        struct Probe(Arc<Mutex<Option<accesskit::Node>>>);
694        impl Render for Probe {
695            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
696                let captured = self.0.clone();
697                canvas(
698                    move |_, window, cx| {
699                        DialogClose::new().trigger(|button| {
700                            let element = button.render(window, cx).into_element();
701                            let mut node = accesskit::Node::new(element.a11y_role().unwrap());
702                            element.write_a11y_info(&mut node);
703                            *captured.lock().unwrap() = Some(node);
704                            element
705                        });
706                    },
707                    |_, _, _, _| {},
708                )
709            }
710        }
711
712        let captured = Arc::new(Mutex::new(None));
713        let result = captured.clone();
714        let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
715        cx.update(|window, cx| window.draw(cx).clear(cx));
716        let node = result.lock().unwrap().take().unwrap();
717        assert_eq!(node.role(), Role::Button);
718        assert_eq!(node.label(), Some("Close"));
719        assert!(node.supports_action(accesskit::Action::Click));
720    }
721
722    #[gpui::test]
723    fn close_trigger_activates_once_and_respects_cancel_veto(cx: &mut gpui::TestAppContext) {
724        use gpui::{KeyDownEvent, KeyUpEvent, Keystroke};
725
726        struct Harness {
727            focus: FocusHandle,
728            button_focus: FocusHandle,
729            handle: DialogHandle,
730            attempts: Rc<Cell<usize>>,
731        }
732        impl Render for Harness {
733            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
734                let attempts = self.attempts.clone();
735                let button_focus = self.button_focus.clone();
736                Dialog::new(cx)
737                    .handle(self.handle.clone())
738                    .focus_handle(self.focus.clone())
739                    .on_cancel(move |_, _, _| {
740                        attempts.set(attempts.get() + 1);
741                        attempts.get() > 1
742                    })
743                    .popup(
744                        DialogClose::new().trigger(move |button| {
745                            button.size(px(100.)).track_focus(&button_focus)
746                        }),
747                    )
748            }
749        }
750
751        cx.update(crate::init);
752        let handle = DialogHandle::new(true);
753        let attempts = Rc::new(Cell::new(0));
754        let (view, cx) = cx.add_window_view({
755            let handle = handle.clone();
756            let attempts = attempts.clone();
757            move |_, cx| Harness {
758                focus: cx.focus_handle(),
759                button_focus: cx.focus_handle(),
760                handle,
761                attempts,
762            }
763        });
764        let viewport = cx.update(|window, cx| {
765            view.read(cx).focus.clone().focus(window, cx);
766            window.draw(cx).clear(cx);
767            window.viewport_size()
768        });
769        let popup_center = point(viewport.width / 2., viewport.height / 2.);
770        cx.simulate_click(popup_center, Default::default());
771        cx.run_until_parked();
772        assert_eq!(attempts.get(), 1);
773        assert!(handle.is_open(), "on_cancel can veto pointer dismissal");
774
775        cx.update(|window, cx| {
776            view.read(cx).button_focus.clone().focus(window, cx);
777            window.draw(cx).clear(cx);
778        });
779        let keystroke = Keystroke::parse("space").unwrap();
780        cx.simulate_event(KeyDownEvent {
781            keystroke: keystroke.clone(),
782            is_held: false,
783            prefer_character_input: false,
784        });
785        cx.simulate_event(KeyUpEvent { keystroke });
786        cx.run_until_parked();
787        assert_eq!(attempts.get(), 2);
788        assert!(!handle.is_open(), "Space uses the same cancel decision");
789    }
790
791    struct TriggerHarness {
792        handle: DialogHandle,
793    }
794    impl Render for TriggerHarness {
795        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
796            DialogTrigger::new(div().size(px(100.))).handle(self.handle.clone())
797        }
798    }
799
800    #[gpui::test]
801    fn trigger_opens_shared_handle_and_reports_reason(cx: &mut gpui::TestAppContext) {
802        let handle = DialogHandle::new(false);
803        let changes = Rc::new(RefCell::new(Vec::new()));
804        *handle.on_open_change.borrow_mut() = Some({
805            let changes = changes.clone();
806            Rc::new(move |open, reason, _, _| changes.borrow_mut().push((open, reason)))
807        });
808        let (_, cx) = cx.add_window_view({
809            let handle = handle.clone();
810            move |_, _| TriggerHarness { handle }
811        });
812        cx.update(|window, cx| window.draw(cx).clear(cx));
813        cx.simulate_click(point(px(20.), px(20.)), Default::default());
814
815        assert!(handle.is_open());
816        assert_eq!(
817            &*changes.borrow(),
818            &[(true, DialogChangeReason::TriggerPress)]
819        );
820    }
821
822    /// The backdrop is the dimming surface every caller hands over as an
823    /// `absolute()` element, so it has to have the host's box to resolve
824    /// against — a collapsed wrapper leaves it zero-sized and invisible.
825    #[gpui::test]
826    fn the_backdrop_fills_the_host(cx: &mut gpui::TestAppContext) {
827        use gpui::{Bounds, canvas};
828        use std::cell::Cell;
829
830        struct Harness {
831            focus: FocusHandle,
832            bounds: Rc<Cell<Bounds<Pixels>>>,
833        }
834        impl Render for Harness {
835            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
836                let bounds = self.bounds.clone();
837                Dialog::new(cx)
838                    .open(true)
839                    .focus_handle(self.focus.clone())
840                    .backdrop(
841                        canvas(
842                            move |bounds_of_backdrop, _, _| bounds.set(bounds_of_backdrop),
843                            |_, _, _, _| {},
844                        )
845                        .absolute()
846                        .size_full(),
847                    )
848                    .popup(div().size(px(100.)))
849            }
850        }
851
852        cx.update(crate::init);
853        let bounds = Rc::new(Cell::new(Bounds::default()));
854        let (_, cx) = cx.add_window_view({
855            let bounds = bounds.clone();
856            move |_, cx| Harness {
857                focus: cx.focus_handle(),
858                bounds,
859            }
860        });
861        let viewport = cx.update(|window, cx| {
862            let viewport = window.viewport_size();
863            window.draw(cx).clear(cx);
864            viewport
865        });
866
867        assert_eq!(
868            bounds.get().size,
869            viewport,
870            "a zero-sized backdrop paints no overlay behind the dialog"
871        );
872    }
873
874    /// The backdrop sits under the popup, so a press on the popup must not
875    /// reach it and dismiss the dialog.
876    #[gpui::test]
877    fn pressing_the_popup_does_not_dismiss(cx: &mut gpui::TestAppContext) {
878        struct Harness(DialogHandle);
879        impl Render for Harness {
880            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
881                Dialog::new(cx)
882                    .handle(self.0.clone())
883                    .backdrop(div().absolute().size_full())
884                    .popup(DialogPopup::new().size(px(100.)))
885            }
886        }
887
888        cx.update(crate::init);
889        let handle = DialogHandle::new(true);
890        let (_, cx) = cx.add_window_view({
891            let handle = handle.clone();
892            move |_, _| Harness(handle)
893        });
894        let viewport = cx.update(|window, cx| {
895            window.draw(cx).clear(cx);
896            window.viewport_size()
897        });
898
899        cx.simulate_click(
900            point(viewport.width / 2., viewport.height / 2.),
901            Default::default(),
902        );
903        assert!(
904            handle.is_open(),
905            "a press on the popup keeps the dialog open"
906        );
907
908        cx.simulate_click(point(px(5.), px(5.)), Default::default());
909        assert!(!handle.is_open(), "a press on the backdrop still dismisses");
910    }
911
912    #[gpui::test]
913    fn the_popup_is_centered_by_default(cx: &mut gpui::TestAppContext) {
914        use gpui::{Bounds, canvas};
915        use std::cell::Cell;
916
917        struct Harness(Rc<Cell<Bounds<Pixels>>>);
918        impl Render for Harness {
919            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
920                let bounds = self.0.clone();
921                Dialog::new(cx).popup(
922                    canvas(move |popup, _, _| bounds.set(popup), |_, _, _, _| {}).size(px(100.)),
923                )
924            }
925        }
926
927        cx.update(crate::init);
928        let bounds = Rc::new(Cell::new(Bounds::default()));
929        let (_, cx) = cx.add_window_view({
930            let bounds = bounds.clone();
931            move |_, _| Harness(bounds)
932        });
933        let viewport = cx.update(|window, cx| {
934            let viewport = window.viewport_size();
935            window.draw(cx).clear(cx);
936            viewport
937        });
938
939        assert_eq!(
940            bounds.get().center(),
941            point(viewport.width / 2., viewport.height / 2.)
942        );
943    }
944}