Skip to main content

gpui_base/
dialog.rs

1use std::{
2    cell::{Cell, RefCell},
3    rc::Rc,
4};
5
6use gpui::{
7    AnyElement, App, ClickEvent, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding,
8    MouseButton, ParentElement, Pixels, RenderOnce, Role, StatefulInteractiveElement as _,
9    StyleRefinement, Styled, Window, anchored, deferred, div, point, prelude::FluentBuilder as _,
10    px,
11};
12use smallvec::SmallVec;
13
14use crate::actions::{Cancel, Confirm};
15use crate::{FocusTrapElement as _, StyledExt as _};
16
17const CONTEXT: &str = "Dialog";
18type Decision = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
19type Closed = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
20type CloseRequest = Rc<dyn Fn(bool, &mut Window, &mut App)>;
21type OpenRequest = Rc<dyn Fn(&mut Window, &mut App)>;
22type OpenChange = Rc<dyn Fn(bool, DialogChangeReason, &mut Window, &mut App)>;
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum DialogChangeReason {
26    TriggerPress,
27    BackdropPress,
28    Cancel,
29    Confirm,
30    Imperative,
31}
32
33#[derive(Clone)]
34pub struct DialogHandle {
35    open: Rc<Cell<bool>>,
36    on_open_change: Rc<RefCell<Option<OpenChange>>>,
37}
38
39impl DialogHandle {
40    pub fn new(open: bool) -> Self {
41        Self {
42            open: Rc::new(Cell::new(open)),
43            on_open_change: Rc::new(RefCell::new(None)),
44        }
45    }
46    pub fn is_open(&self) -> bool {
47        self.open.get()
48    }
49    pub fn open(&self, window: &mut Window, cx: &mut App) {
50        self.set_open(true, DialogChangeReason::Imperative, window, cx);
51    }
52    pub fn close(&self, window: &mut Window, cx: &mut App) {
53        self.set_open(false, DialogChangeReason::Imperative, window, cx);
54    }
55    pub(crate) fn set_open(
56        &self,
57        open: bool,
58        reason: DialogChangeReason,
59        window: &mut Window,
60        cx: &mut App,
61    ) {
62        if self.open.replace(open) == open {
63            return;
64        }
65        let callback = self.on_open_change.borrow().clone();
66        if let Some(callback) = callback {
67            callback(open, reason, window, cx);
68        }
69        window.refresh();
70    }
71}
72
73fn request_open_change(
74    handle: &Option<DialogHandle>,
75    callback: &Option<OpenChange>,
76    open: bool,
77    reason: DialogChangeReason,
78    window: &mut Window,
79    cx: &mut App,
80) {
81    if let Some(handle) = handle {
82        handle.set_open(open, reason, window, cx);
83    } else if let Some(callback) = callback {
84        callback(open, reason, window, cx);
85    }
86}
87
88pub fn init(cx: &mut App) {
89    cx.bind_keys([
90        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
91        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
92    ]);
93}
94
95impl Dialog {
96    pub fn on_ok(
97        mut self,
98        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
99    ) -> Self {
100        self.on_ok = Rc::new(handler);
101        self
102    }
103
104    pub fn on_cancel(
105        mut self,
106        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
107    ) -> Self {
108        self.on_cancel = Rc::new(handler);
109        self
110    }
111
112    pub fn on_close(
113        mut self,
114        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
115    ) -> Self {
116        self.on_close = Rc::new(handler);
117        self
118    }
119}
120
121/// Unstyled modal host owning focus, keyboard actions, dismissal, and callback ordering.
122#[derive(IntoElement)]
123pub struct Dialog {
124    style: StyleRefinement,
125    focus: FocusHandle,
126    role: Role,
127    layer: usize,
128    keyboard: bool,
129    overlay_closable: bool,
130    topmost: bool,
131    dismiss_below_y: Pixels,
132    backdrop: Option<AnyElement>,
133    popup: Option<AnyElement>,
134    children: SmallVec<[AnyElement; 2]>,
135    on_ok: Decision,
136    on_cancel: Decision,
137    on_close: Closed,
138    request_close: CloseRequest,
139    handle: Option<DialogHandle>,
140    open: bool,
141    on_open_change: Option<OpenChange>,
142}
143
144/// Unstyled trigger that owns pointer activation for opening a dialog.
145#[derive(IntoElement)]
146pub struct DialogTrigger {
147    trigger: AnyElement,
148    open: OpenRequest,
149    handle: Option<DialogHandle>,
150}
151
152impl DialogTrigger {
153    pub fn new(trigger: impl IntoElement) -> Self {
154        Self {
155            trigger: trigger.into_any_element(),
156            open: Rc::new(|_, _| {}),
157            handle: None,
158        }
159    }
160    pub fn handle(mut self, handle: DialogHandle) -> Self {
161        self.handle = Some(handle);
162        self
163    }
164
165    pub fn on_open(mut self, open: impl Fn(&mut Window, &mut App) + 'static) -> Self {
166        self.open = Rc::new(open);
167        self
168    }
169}
170
171impl RenderOnce for DialogTrigger {
172    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
173        div()
174            .on_mouse_down(MouseButton::Left, move |_, window, cx| {
175                if let Some(handle) = self.handle.as_ref() {
176                    handle.set_open(true, DialogChangeReason::TriggerPress, window, cx);
177                }
178                (self.open)(window, cx);
179                cx.stop_propagation();
180            })
181            .child(self.trigger)
182    }
183}
184
185macro_rules! dialog_part {
186    ($(#[$meta:meta])* $name:ident, $id:literal) => {
187        $(#[$meta])*
188        #[derive(IntoElement)]
189        pub struct $name {
190            style: StyleRefinement,
191            children: SmallVec<[AnyElement; 2]>,
192        }
193
194        impl $name {
195            pub fn new() -> Self {
196                Self {
197                    style: StyleRefinement::default(),
198                    children: SmallVec::new(),
199                }
200            }
201        }
202
203        impl Default for $name {
204            fn default() -> Self {
205                Self::new()
206            }
207        }
208
209        impl Styled for $name {
210            fn style(&mut self) -> &mut StyleRefinement {
211                &mut self.style
212            }
213        }
214
215        impl ParentElement for $name {
216            fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
217                self.children.extend(elements);
218            }
219        }
220
221        impl RenderOnce for $name {
222            fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
223                div()
224                    .id($id)
225                    .children(self.children)
226                    .refine_style(&self.style)
227            }
228        }
229    };
230}
231
232dialog_part!(
233    /// Unstyled backdrop part rendered behind a dialog popup.
234    DialogBackdrop,
235    "dialog-backdrop"
236);
237
238dialog_part!(
239    /// Unstyled popup part containing dialog content.
240    DialogPopup,
241    "dialog-popup"
242);
243
244/// Unstyled title slot for a dialog surface.
245#[derive(IntoElement)]
246pub struct DialogTitle {
247    base: gpui::Div,
248    style: StyleRefinement,
249    children: SmallVec<[AnyElement; 2]>,
250}
251
252impl DialogTitle {
253    pub fn new() -> Self {
254        Self {
255            base: div(),
256            style: StyleRefinement::default(),
257            children: SmallVec::new(),
258        }
259    }
260}
261
262impl Default for DialogTitle {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267impl Styled for DialogTitle {
268    fn style(&mut self) -> &mut StyleRefinement {
269        &mut self.style
270    }
271}
272impl ParentElement for DialogTitle {
273    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
274        self.children.extend(elements);
275    }
276}
277impl RenderOnce for DialogTitle {
278    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
279        self.base
280            .id("dialog-title")
281            .children(self.children)
282            .refine_style(&self.style)
283    }
284}
285
286/// Unstyled descriptive-content slot for a dialog surface.
287#[derive(IntoElement)]
288pub struct DialogDescription {
289    base: gpui::Div,
290    style: StyleRefinement,
291    children: SmallVec<[AnyElement; 2]>,
292}
293
294impl DialogDescription {
295    pub fn new() -> Self {
296        Self {
297            base: div(),
298            style: StyleRefinement::default(),
299            children: SmallVec::new(),
300        }
301    }
302}
303
304impl Default for DialogDescription {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309impl Styled for DialogDescription {
310    fn style(&mut self) -> &mut StyleRefinement {
311        &mut self.style
312    }
313}
314impl ParentElement for DialogDescription {
315    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
316        self.children.extend(elements);
317    }
318}
319impl RenderOnce for DialogDescription {
320    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
321        self.base
322            .id("dialog-description")
323            .children(self.children)
324            .refine_style(&self.style)
325    }
326}
327
328/// Wrapper that dispatches the dialog cancel action when activated.
329#[derive(IntoElement)]
330pub struct DialogClose {
331    style: StyleRefinement,
332    children: SmallVec<[AnyElement; 1]>,
333}
334
335impl DialogClose {
336    pub fn new() -> Self {
337        Self {
338            style: StyleRefinement::default(),
339            children: SmallVec::new(),
340        }
341    }
342}
343impl Default for DialogClose {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348impl ParentElement for DialogClose {
349    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
350        self.children.extend(elements);
351    }
352}
353impl Styled for DialogClose {
354    fn style(&mut self) -> &mut StyleRefinement {
355        &mut self.style
356    }
357}
358impl RenderOnce for DialogClose {
359    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
360        div()
361            .id("dialog-close")
362            .on_click(|_, window, cx| window.dispatch_action(Box::new(Cancel), cx))
363            .children(self.children)
364            .refine_style(&self.style)
365    }
366}
367
368impl Dialog {
369    pub fn new(cx: &mut App) -> Self {
370        Self {
371            style: StyleRefinement::default(),
372            focus: cx.focus_handle(),
373            role: Role::Dialog,
374            layer: 0,
375            keyboard: true,
376            overlay_closable: true,
377            topmost: true,
378            dismiss_below_y: px(0.),
379            backdrop: None,
380            popup: None,
381            children: SmallVec::new(),
382            on_ok: Rc::new(|_, _, _| true),
383            on_cancel: Rc::new(|_, _, _| true),
384            on_close: Rc::new(|_, _, _| {}),
385            request_close: Rc::new(|_, _, _| {}),
386            handle: None,
387            open: true,
388            on_open_change: None,
389        }
390    }
391    pub fn open(mut self, open: bool) -> Self {
392        self.open = open;
393        self
394    }
395    pub fn handle(mut self, handle: DialogHandle) -> Self {
396        if let Some(callback) = self.on_open_change.as_ref() {
397            *handle.on_open_change.borrow_mut() = Some(callback.clone());
398        }
399        self.handle = Some(handle);
400        self
401    }
402    pub fn on_open_change(
403        mut self,
404        handler: impl Fn(bool, DialogChangeReason, &mut Window, &mut App) + 'static,
405    ) -> Self {
406        let handler: OpenChange = Rc::new(handler);
407        if let Some(handle) = self.handle.as_ref() {
408            *handle.on_open_change.borrow_mut() = Some(handler.clone());
409        }
410        self.on_open_change = Some(handler);
411        self
412    }
413
414    pub fn backdrop(mut self, element: impl IntoElement) -> Self {
415        self.backdrop = Some(element.into_any_element());
416        self
417    }
418    pub fn popup(mut self, element: impl IntoElement) -> Self {
419        self.popup = Some(element.into_any_element());
420        self
421    }
422    pub fn close_on_escape(mut self, value: bool) -> Self {
423        self.keyboard = value;
424        self
425    }
426    pub fn close_on_backdrop_press(mut self, value: bool) -> Self {
427        self.overlay_closable = value;
428        self
429    }
430    pub fn dismiss_below_y(mut self, value: Pixels) -> Self {
431        self.dismiss_below_y = value;
432        self
433    }
434    pub(crate) fn role(mut self, role: Role) -> Self {
435        self.role = role;
436        self
437    }
438    #[doc(hidden)]
439    pub fn layer(mut self, index: usize, topmost: bool) -> Self {
440        self.layer = index;
441        self.topmost = topmost;
442        self
443    }
444    #[doc(hidden)]
445    pub fn focus_handle(mut self, value: FocusHandle) -> Self {
446        self.focus = value;
447        self
448    }
449    #[doc(hidden)]
450    pub fn request_close(
451        mut self,
452        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
453    ) -> Self {
454        self.request_close = Rc::new(handler);
455        self
456    }
457}
458
459impl Styled for Dialog {
460    fn style(&mut self) -> &mut StyleRefinement {
461        &mut self.style
462    }
463}
464impl ParentElement for Dialog {
465    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
466        self.children.extend(elements);
467    }
468}
469
470impl RenderOnce for Dialog {
471    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
472        let open = self
473            .handle
474            .as_ref()
475            .map_or(self.open, DialogHandle::is_open);
476        if !open {
477            return div().into_any_element();
478        }
479        let request_close = self.request_close;
480        let cancel = self.on_cancel.clone();
481        let confirm = self.on_ok.clone();
482        let closed = self.on_close.clone();
483        let overlay_closable = self.overlay_closable && self.topmost;
484        let dismiss_below_y = self.dismiss_below_y;
485        let escape_handle = self.handle.clone();
486        let confirm_handle = self.handle.clone();
487        let backdrop_handle = self.handle.clone();
488        let escape_change = self.on_open_change.clone();
489        let confirm_change = self.on_open_change.clone();
490        let backdrop_change = self.on_open_change.clone();
491        let viewport = window.viewport_size();
492
493        deferred(
494            anchored().position(point(px(0.), px(0.))).child(
495                div()
496                    .id(("dialog-host", self.layer))
497                    .absolute()
498                    .top_0()
499                    .left_0()
500                    .w(viewport.width)
501                    .h(viewport.height)
502                    .role(self.role)
503                    .track_focus(&self.focus)
504                    .focus_trap(format!("dialog-{}", self.layer), &self.focus)
505                    .when(self.keyboard, |this| this.key_context(CONTEXT))
506                    .map(|this| {
507                        let request_cancel = request_close.clone();
508                        let request_confirm = request_close.clone();
509                        let closed_cancel = closed.clone();
510                        this.on_action(move |_: &Cancel, window, cx| {
511                            let event = ClickEvent::default();
512                            if cancel(&event, window, cx) {
513                                request_open_change(
514                                    &escape_handle,
515                                    &escape_change,
516                                    false,
517                                    DialogChangeReason::Cancel,
518                                    window,
519                                    cx,
520                                );
521                                request_cancel(false, window, cx);
522                                closed_cancel(&event, window, cx);
523                            }
524                        })
525                        .on_action(move |_: &Confirm, window, cx| {
526                            let event = ClickEvent::default();
527                            if confirm(&event, window, cx) {
528                                request_open_change(
529                                    &confirm_handle,
530                                    &confirm_change,
531                                    false,
532                                    DialogChangeReason::Confirm,
533                                    window,
534                                    cx,
535                                );
536                                request_confirm(true, window, cx);
537                                closed(&event, window, cx);
538                            }
539                        })
540                    })
541                    .when_some(self.backdrop, |this, backdrop| {
542                        let cancel = self.on_cancel.clone();
543                        let closed = self.on_close.clone();
544                        let request_close = request_close.clone();
545                        this.child(
546                            div()
547                                // The backdrop covers the host, so a caller's
548                                // `absolute()` surface has a box to fill.
549                                .absolute()
550                                .inset_0()
551                                .on_any_mouse_down(move |event, window, cx| {
552                                    if event.position.y < dismiss_below_y {
553                                        return;
554                                    }
555                                    let button = event.button;
556                                    cx.stop_propagation();
557                                    let event = ClickEvent::default();
558                                    if button == MouseButton::Left
559                                        && overlay_closable
560                                        && cancel(&event, window, cx)
561                                    {
562                                        request_open_change(
563                                            &backdrop_handle,
564                                            &backdrop_change,
565                                            false,
566                                            DialogChangeReason::BackdropPress,
567                                            window,
568                                            cx,
569                                        );
570                                        request_close(false, window, cx);
571                                        closed(&event, window, cx);
572                                    }
573                                })
574                                .child(backdrop),
575                        )
576                    })
577                    .children(self.popup)
578                    .children(self.children)
579                    .refine_style(&self.style),
580            ),
581        )
582        .with_priority(10 + self.layer)
583        .into_any_element()
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use gpui::{Context, Render, point};
591    use std::{cell::RefCell, rc::Rc};
592
593    struct TriggerHarness {
594        handle: DialogHandle,
595    }
596    impl Render for TriggerHarness {
597        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
598            DialogTrigger::new(div().size(px(100.))).handle(self.handle.clone())
599        }
600    }
601
602    #[gpui::test]
603    fn trigger_opens_shared_handle_and_reports_reason(cx: &mut gpui::TestAppContext) {
604        let handle = DialogHandle::new(false);
605        let changes = Rc::new(RefCell::new(Vec::new()));
606        *handle.on_open_change.borrow_mut() = Some({
607            let changes = changes.clone();
608            Rc::new(move |open, reason, _, _| changes.borrow_mut().push((open, reason)))
609        });
610        let (_, cx) = cx.add_window_view({
611            let handle = handle.clone();
612            move |_, _| TriggerHarness { handle }
613        });
614        cx.update(|window, cx| window.draw(cx).clear(cx));
615        cx.simulate_click(point(px(20.), px(20.)), Default::default());
616
617        assert!(handle.is_open());
618        assert_eq!(
619            &*changes.borrow(),
620            &[(true, DialogChangeReason::TriggerPress)]
621        );
622    }
623
624    /// The backdrop is the dimming surface every caller hands over as an
625    /// `absolute()` element, so it has to have the host's box to resolve
626    /// against — a collapsed wrapper leaves it zero-sized and invisible.
627    #[gpui::test]
628    fn the_backdrop_fills_the_host(cx: &mut gpui::TestAppContext) {
629        use gpui::{Bounds, canvas};
630        use std::cell::Cell;
631
632        struct Harness {
633            focus: FocusHandle,
634            bounds: Rc<Cell<Bounds<Pixels>>>,
635        }
636        impl Render for Harness {
637            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
638                let bounds = self.bounds.clone();
639                Dialog::new(cx)
640                    .open(true)
641                    .focus_handle(self.focus.clone())
642                    .backdrop(
643                        canvas(
644                            move |bounds_of_backdrop, _, _| bounds.set(bounds_of_backdrop),
645                            |_, _, _, _| {},
646                        )
647                        .absolute()
648                        .size_full(),
649                    )
650                    .popup(div().size(px(100.)))
651            }
652        }
653
654        cx.update(crate::init);
655        let bounds = Rc::new(Cell::new(Bounds::default()));
656        let (_, cx) = cx.add_window_view({
657            let bounds = bounds.clone();
658            move |_, cx| Harness {
659                focus: cx.focus_handle(),
660                bounds,
661            }
662        });
663        let viewport = cx.update(|window, cx| {
664            let viewport = window.viewport_size();
665            window.draw(cx).clear(cx);
666            viewport
667        });
668
669        assert_eq!(
670            bounds.get().size,
671            viewport,
672            "a zero-sized backdrop paints no overlay behind the dialog"
673        );
674    }
675}