Skip to main content

gpui_component/dialog/
dialog.rs

1use std::{rc::Rc, sync::LazyLock, time::Duration};
2
3use gpui::{
4    Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Edges, FocusHandle, Hsla,
5    InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
6    StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point,
7    prelude::FluentBuilder, px,
8};
9use gpui_base::{ElementExt as _, TextSelectionScopeId};
10use rust_i18n::t;
11
12use crate::{
13    ActiveTheme as _, IconName, Root, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _,
14    animation::cubic_bezier,
15    button::{Button, ButtonVariant, ButtonVariants as _},
16    dialog::{DialogContent, DialogTitle},
17    scroll::ScrollableElement as _,
18    v_flex,
19};
20
21pub static ANIMATION_DURATION: LazyLock<Duration> = LazyLock::new(|| Duration::from_secs_f64(0.25));
22pub use gpui_base::actions::{Cancel, Confirm};
23
24/// Dialog button props.
25#[derive(Clone)]
26pub struct DialogButtonProps {
27    pub(crate) ok_text: Option<SharedString>,
28    pub(crate) ok_variant: ButtonVariant,
29    pub(crate) cancel_text: Option<SharedString>,
30    pub(crate) cancel_variant: ButtonVariant,
31    pub(crate) show_cancel: bool,
32    pub(crate) on_ok: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>,
33    pub(crate) on_cancel: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>,
34    pub(crate) on_close: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
35}
36
37impl Default for DialogButtonProps {
38    fn default() -> Self {
39        Self {
40            ok_text: None,
41            ok_variant: ButtonVariant::Primary,
42            cancel_text: None,
43            cancel_variant: ButtonVariant::default(),
44            show_cancel: false,
45            on_ok: Rc::new(|_, _, _| true),
46            on_cancel: Rc::new(|_, _, _| true),
47            on_close: Rc::new(|_, _, _| {}),
48        }
49    }
50}
51
52impl DialogButtonProps {
53    /// Sets the text of the OK button. Default is `OK`.
54    pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
55        self.ok_text = Some(ok_text.into());
56        self
57    }
58
59    /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
60    pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
61        self.ok_variant = ok_variant;
62        self
63    }
64
65    /// Sets the text of the Cancel button. Default is `Cancel`.
66    pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
67        self.cancel_text = Some(cancel_text.into());
68        self
69    }
70
71    /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`.
72    pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
73        self.cancel_variant = cancel_variant;
74        self
75    }
76
77    /// Sets whether to show the Cancel button. Default is `false`.
78    pub fn show_cancel(mut self, show_cancel: bool) -> Self {
79        self.show_cancel = show_cancel;
80        self
81    }
82
83    /// Sets the callback for when the dialog is has been confirmed.
84    ///
85    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
86    pub fn on_ok(
87        mut self,
88        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
89    ) -> Self {
90        self.on_ok = Rc::new(on_ok);
91        self
92    }
93
94    /// Sets the callback for when the dialog is has been canceled.
95    ///
96    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
97    pub fn on_cancel(
98        mut self,
99        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
100    ) -> Self {
101        self.on_cancel = Rc::new(on_cancel);
102        self
103    }
104
105    pub(crate) fn render_ok(&self, _: &mut Window, _: &mut App) -> AnyElement {
106        let ok_text = self
107            .ok_text
108            .clone()
109            .unwrap_or_else(|| t!("Dialog.ok").into());
110        let ok_variant = self.ok_variant;
111
112        Button::new("ok")
113            .label(ok_text)
114            .with_variant(ok_variant)
115            .on_click(|_, window, cx| {
116                window.dispatch_action(Box::new(Confirm { secondary: false }), cx)
117            })
118            .into_any_element()
119    }
120
121    pub(crate) fn render_cancel(&self, _: &mut Window, _: &mut App) -> AnyElement {
122        let cancel_text = self
123            .cancel_text
124            .clone()
125            .unwrap_or_else(|| t!("Dialog.cancel").into());
126        let cancel_variant = self.cancel_variant;
127
128        Button::new("cancel")
129            .label(cancel_text)
130            .with_variant(cancel_variant)
131            .on_click(|_, window, cx| window.dispatch_action(Box::new(Cancel), cx))
132            .into_any_element()
133    }
134}
135
136type ContentBuilderFn = Rc<dyn Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static>;
137
138#[derive(Clone)]
139pub(crate) struct DialogProps {
140    width: Pixels,
141    max_width: Option<Pixels>,
142    margin_top: Option<Pixels>,
143    close_button: bool,
144
145    overlay: bool,
146    overlay_closable: bool,
147    pub(crate) overlay_visible: bool,
148    keyboard: bool,
149}
150
151impl Default for DialogProps {
152    fn default() -> Self {
153        Self {
154            margin_top: None,
155            width: px(448.),
156            max_width: None,
157            overlay: true,
158            keyboard: true,
159            overlay_visible: false,
160            close_button: true,
161            overlay_closable: true,
162        }
163    }
164}
165
166enum BaseDialogRoot {
167    Dialog(gpui_base::Dialog),
168    AlertDialog(gpui_base::AlertDialog),
169}
170
171macro_rules! map_base_root {
172    ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
173        match $self {
174            BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
175            BaseDialogRoot::AlertDialog(root) => {
176                BaseDialogRoot::AlertDialog(root.$method($($arg),*))
177            }
178        }
179    };
180}
181
182impl BaseDialogRoot {
183    fn layer(self, index: usize, topmost: bool) -> Self {
184        map_base_root!(self, layer(index, topmost))
185    }
186    fn focus_handle(self, focus: FocusHandle) -> Self {
187        map_base_root!(self, focus_handle(focus))
188    }
189    fn close_on_escape(self, value: bool) -> Self {
190        map_base_root!(self, close_on_escape(value))
191    }
192    fn close_on_backdrop_press(self, value: bool) -> Self {
193        match self {
194            Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
195            Self::AlertDialog(root) => Self::AlertDialog(root),
196        }
197    }
198    fn dismiss_below_y(self, value: Pixels) -> Self {
199        map_base_root!(self, dismiss_below_y(value))
200    }
201    fn backdrop(self, element: impl IntoElement) -> Self {
202        map_base_root!(self, backdrop(element))
203    }
204    fn popup(self, element: impl IntoElement) -> Self {
205        map_base_root!(self, popup(element))
206    }
207    fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
208        map_base_root!(self, on_ok(handler))
209    }
210    fn on_cancel(
211        self,
212        handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
213    ) -> Self {
214        map_base_root!(self, on_cancel(handler))
215    }
216    fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
217        map_base_root!(self, on_close(handler))
218    }
219    fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
220        map_base_root!(self, request_close(handler))
221    }
222}
223
224impl IntoElement for BaseDialogRoot {
225    type Element = <gpui_base::Dialog as IntoElement>::Element;
226    fn into_element(self) -> Self::Element {
227        match self {
228            Self::Dialog(root) => root.into_element(),
229            Self::AlertDialog(root) => root.into_element(),
230        }
231    }
232}
233
234/// A modal to display content in a dialog box.
235#[derive(IntoElement)]
236pub struct Dialog {
237    base: Option<BaseDialogRoot>,
238    pub(crate) style: StyleRefinement,
239    children: Vec<AnyElement>,
240    trigger: Option<AnyElement>,
241    title: Option<AnyElement>,
242    pub(crate) header: Option<AnyElement>,
243    pub(crate) footer: Option<AnyElement>,
244    pub(crate) content_builder: Option<ContentBuilderFn>,
245    pub(crate) props: DialogProps,
246
247    pub(super) button_props: DialogButtonProps,
248
249    /// This will be change when open the dialog, the focus handle is create when open the dialog.
250    pub(crate) focus_handle: FocusHandle,
251    pub(crate) layer_ix: usize,
252    pub(crate) selection_scope: TextSelectionScopeId,
253}
254
255pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
256    if !overlay {
257        return hsla(0., 0., 0., 0.);
258    }
259
260    cx.theme().overlay
261}
262
263impl Dialog {
264    /// Create a new dialog.
265    pub fn new(cx: &mut App) -> Self {
266        Self {
267            base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
268            focus_handle: cx.focus_handle(),
269            style: StyleRefinement::default(),
270            trigger: None,
271            title: None,
272            header: None,
273            footer: None,
274            content_builder: None,
275            props: DialogProps::default(),
276            children: Vec::new(),
277            layer_ix: 0,
278            selection_scope: TextSelectionScopeId::default(),
279            button_props: DialogButtonProps::default(),
280        }
281    }
282
283    /// Sets the trigger element for the dialog.
284    ///
285    /// When a trigger is set, the dialog will render as a trigger button that opens the dialog when clicked.
286    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
287        self.trigger = Some(trigger.into_any_element());
288        self
289    }
290
291    /// Sets the content of the dialog.
292    pub fn content<F>(mut self, builder: F) -> Self
293    where
294        F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
295    {
296        self.content_builder = Some(Rc::new(builder));
297        self
298    }
299
300    /// Sets the title of the dialog.
301    pub fn title(mut self, title: impl IntoElement) -> Self {
302        self.title = Some(title.into_any_element());
303        self
304    }
305
306    /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons.
307    ///
308    /// When you set the footer, the `button_props` will be ignored, you need to render the action buttons by yourself.
309    pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
310        self.header = Some(header.into_any_element());
311        self
312    }
313
314    /// Sets the footer of the dialog, the footer will render at the bottom of the dialog, usually for action buttons.
315    ///
316    /// When you set the footer, the `button_props` will be ignored, you need to render the action buttons by yourself.
317    pub fn footer(mut self, footer: impl IntoElement) -> Self {
318        self.footer = Some(footer.into_any_element());
319        self
320    }
321
322    /// Set the button props of the dialog.
323    pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
324        self.button_props = button_props;
325        self
326    }
327    pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
328        self.base = Some(BaseDialogRoot::AlertDialog(base));
329        self.props.overlay_closable = false;
330        self
331    }
332
333    /// Sets the callback for when the dialog is closed.
334    ///
335    /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
336    pub fn on_close(
337        mut self,
338        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
339    ) -> Self {
340        self.button_props.on_close = Rc::new(on_close);
341        self
342    }
343
344    /// Sets the callback for when the dialog is has been confirmed.
345    ///
346    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
347    pub fn on_ok(
348        mut self,
349        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
350    ) -> Self {
351        self.button_props = self.button_props.on_ok(on_ok);
352        self
353    }
354
355    /// Sets the callback for when the dialog is has been canceled.
356    ///
357    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
358    pub fn on_cancel(
359        mut self,
360        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
361    ) -> Self {
362        self.button_props = self.button_props.on_cancel(on_cancel);
363        self
364    }
365
366    /// Sets the false to hide close icon, default: true
367    pub fn close_button(mut self, close_button: bool) -> Self {
368        self.props.close_button = close_button;
369        self
370    }
371
372    /// Set the top offset of the dialog, defaults to None, will use the 1/10 of the viewport height.
373    pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
374        self.props.margin_top = Some(margin_top.into());
375        self
376    }
377
378    /// Sets the width of the dialog, defaults to 448px.
379    ///
380    /// See also [`Self::width`]
381    pub fn w(mut self, width: impl Into<Pixels>) -> Self {
382        self.props.width = width.into();
383        self
384    }
385
386    /// Sets the width of the dialog, defaults to 448px.
387    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
388        self.props.width = width.into();
389        self
390    }
391
392    /// Set the maximum width of the dialog, defaults to `None`.
393    pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
394        self.props.max_width = Some(max_width.into());
395        self
396    }
397
398    /// Set the overlay of the dialog, defaults to `true`.
399    pub fn overlay(mut self, overlay: bool) -> Self {
400        self.props.overlay = overlay;
401        self
402    }
403
404    /// Set the overlay closable of the dialog, defaults to `true`.
405    ///
406    /// When the overlay is clicked, the dialog will be closed.
407    pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
408        self.props.overlay_closable = overlay_closable;
409        self
410    }
411
412    /// Set whether to support keyboard esc to close the dialog, defaults to `true`.
413    pub fn keyboard(mut self, keyboard: bool) -> Self {
414        self.props.keyboard = keyboard;
415        self
416    }
417
418    pub(crate) fn has_overlay(&self) -> bool {
419        self.props.overlay
420    }
421
422    pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
423        self.props = props;
424        self
425    }
426
427    fn defer_close_dialog(window: &mut Window, cx: &mut App) {
428        Root::update(window, cx, |root, window, cx| {
429            root.defer_close_dialog(window, cx);
430        });
431    }
432}
433
434impl ParentElement for Dialog {
435    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
436        self.children.extend(elements);
437    }
438}
439
440impl Styled for Dialog {
441    fn style(&mut self) -> &mut gpui::StyleRefinement {
442        &mut self.style
443    }
444}
445
446impl Dialog {
447    fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
448        let content_builder = self.content_builder.clone();
449        let style = self.style.clone();
450        let props = self.props.clone();
451        let button_props = self.button_props.clone();
452
453        gpui_base::DialogTrigger::new(trigger)
454            .on_open(move |window, cx| {
455                let content_builder = content_builder.clone();
456                let style = style.clone();
457                let props = props.clone();
458                let button_props = button_props.clone();
459                window.open_dialog(cx, move |dialog, _, _| {
460                    dialog
461                        .refine_style(&style)
462                        .button_props(button_props.clone())
463                        .with_props(props.clone())
464                        .content({
465                            let content_builder = content_builder.clone();
466                            move |content, window, cx| {
467                                if let Some(builder) = content_builder.clone() {
468                                    builder(content, window, cx)
469                                } else {
470                                    content
471                                }
472                            }
473                        })
474                });
475            })
476            .into_any_element()
477    }
478}
479
480impl RenderOnce for Dialog {
481    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
482        if let Some(trigger) = self.trigger.take() {
483            return self.render_trigger(trigger, window, cx);
484        }
485
486        let layer_ix = self.layer_ix;
487        let selection_scope = self.selection_scope;
488        let on_close = self.button_props.on_close.clone();
489        let on_ok = self.button_props.on_ok.clone();
490        let on_cancel = self.button_props.on_cancel.clone();
491
492        let window_paddings = crate::window_border::window_paddings(window);
493        let view_size = window.viewport_size()
494            - gpui::size(
495                window_paddings.left + window_paddings.right,
496                window_paddings.top + window_paddings.bottom,
497            );
498        let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + px(layer_ix as f32 * 16.);
499        let x = view_size.width / 2. - self.props.width / 2.;
500
501        let base_size = window.text_style().font_size;
502        let rem_size = window.rem_size();
503
504        let mut paddings = Edges::all(px(16.));
505        if let Some(pl) = self.style.padding.left {
506            paddings.left = pl.to_pixels(base_size, rem_size);
507        }
508        if let Some(pr) = self.style.padding.right {
509            paddings.right = pr.to_pixels(base_size, rem_size);
510        }
511        if let Some(pt) = self.style.padding.top {
512            paddings.top = pt.to_pixels(base_size, rem_size);
513        }
514        if let Some(pb) = self.style.padding.bottom {
515            paddings.bottom = pb.to_pixels(base_size, rem_size);
516        }
517
518        // x1 = 1/3, x2 = 2/3 make the bezier's time mapping the identity,
519        // preserving the trajectory this dialog was tuned with before
520        // `cubic_bezier` solved for x; vaul's (0.32, 0.72, 0., 1.) is far
521        // more front-loaded under the CSS-correct solver.
522        let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
523            1. / 3.,
524            0.72,
525            2. / 3.,
526            1.,
527        ));
528
529        anchored()
530            .position(point(window_paddings.left, window_paddings.top))
531            .snap_to_window()
532            .child(
533                div()
534                    .id("dialog")
535                    .occlude()
536                    .w(view_size.width)
537                    .h(view_size.height)
538                    .child(
539                        self.base
540                            .take()
541                            .expect("Dialog base host is always present")
542                            .layer(
543                                layer_ix,
544                                (self.layer_ix + 1) == Root::read(window, cx).active_dialogs.len(),
545                            )
546                            .focus_handle(self.focus_handle.clone())
547                            .close_on_escape(self.props.keyboard)
548                            .close_on_backdrop_press(self.props.overlay_closable)
549                            .dismiss_below_y(TITLE_BAR_HEIGHT)
550                            .when(self.props.overlay, |this| {
551                                this.backdrop(
552                                    div()
553                                        .absolute()
554                                        .size_full()
555                                        .window_control_area(WindowControlArea::Drag)
556                                        .when(self.props.overlay_visible, |overlay| {
557                                            overlay.bg(overlay_color(true, cx))
558                                        }),
559                                )
560                            })
561                            .on_ok(move |event, window, cx| on_ok(event, window, cx))
562                            .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
563                            .on_close(move |event, window, cx| on_close(event, window, cx))
564                            .request_close(move |deferred, window, cx| {
565                                if deferred {
566                                    Self::defer_close_dialog(window, cx);
567                                } else {
568                                    window.close_dialog(cx);
569                                }
570                            })
571                            .popup(
572                                v_flex()
573                                    .id(layer_ix)
574                                    .bg(cx.theme().tokens.background)
575                                    .border_1()
576                                    .border_color(cx.theme().border)
577                                    .rounded(cx.theme().radius_lg)
578                                    .min_h_24()
579                                    .pt(paddings.top)
580                                    .pb(paddings.bottom)
581                                    .gap(paddings.top.max(px(8.)))
582                                    .refine_style(&self.style)
583                                    .px_0()
584                                    // There style is high priority, can't be overridden.
585                                    .absolute()
586                                    .occlude()
587                                    .relative()
588                                    .left(x)
589                                    .top(y)
590                                    .w(self.props.width)
591                                    .when_some(self.props.max_width, |this, w| this.max_w(w))
592                                    .child(
593                                        v_flex()
594                                            .flex_1()
595                                            .overflow_hidden()
596                                            .gap_y_2()
597                                            .when_some(self.header, |this, header| {
598                                                this.child(
599                                                    div()
600                                                        .pl(paddings.left)
601                                                        .pr(paddings.right)
602                                                        .child(header),
603                                                )
604                                            })
605                                            .when_some(self.title, |this, title| {
606                                                this.child(
607                                                    DialogTitle::new()
608                                                        .pl(paddings.left)
609                                                        .pr(paddings.right)
610                                                        .child(title),
611                                                )
612                                            })
613                                            .when_some(self.content_builder, |this, builder| {
614                                                this.child(builder(
615                                                    DialogContent::new()
616                                                        .gap(paddings.bottom)
617                                                        .pl(paddings.left)
618                                                        .pr(paddings.right),
619                                                    window,
620                                                    cx,
621                                                ))
622                                            })
623                                            .when(!self.children.is_empty(), |this| {
624                                                this.child(
625                                                    div().flex_1().overflow_hidden().child(
626                                                        // Body
627                                                        v_flex()
628                                                            .size_full()
629                                                            .overflow_y_scrollbar()
630                                                            .pl(paddings.left)
631                                                            .pr(paddings.right)
632                                                            .children(self.children),
633                                                    ),
634                                                )
635                                            }),
636                                    )
637                                    .when_some(self.footer, |this, footer| {
638                                        this.child(
639                                            div()
640                                                .pl(paddings.left)
641                                                .pr(paddings.right)
642                                                .child(footer),
643                                        )
644                                    })
645                                    .children(self.props.close_button.then(|| {
646                                        let top = (paddings.top - px(10.)).max(px(8.));
647                                        let right = (paddings.right - px(10.)).max(px(8.));
648
649                                        gpui_base::DialogClose::new()
650                                            .absolute()
651                                            .top(top)
652                                            .right(right)
653                                            .child(
654                                                Button::new("close")
655                                                    .small()
656                                                    .ghost()
657                                                    .icon(IconName::Close),
658                                            )
659                                    }))
660                                    .with_animation(
661                                        "slide-down",
662                                        animation.clone(),
663                                        move |this, delta| {
664                                            // This is equivalent to `shadow_xl` with an extra opacity.
665                                            let shadow = vec![
666                                                BoxShadow {
667                                                    color: hsla(0., 0., 0., 0.1 * delta),
668                                                    offset: point(px(0.), px(20.)),
669                                                    blur_radius: px(25.),
670                                                    spread_radius: px(-5.),
671                                                    inset: false,
672                                                },
673                                                BoxShadow {
674                                                    color: hsla(0., 0., 0., 0.1 * delta),
675                                                    offset: point(px(0.), px(8.)),
676                                                    blur_radius: px(10.),
677                                                    spread_radius: px(-6.),
678                                                    inset: false,
679                                                },
680                                            ];
681                                            this.top(y * delta).shadow(shadow)
682                                        },
683                                    )
684                                    .text_selection_scope(selection_scope),
685                            ),
686                    )
687                    .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
688            )
689            .into_any_element()
690    }
691}