gpui_component/
dialog.rs

1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4    Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, Edges,
5    FocusHandle, Hsla, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement,
6    Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla,
7    point, prelude::FluentBuilder, px, relative,
8};
9use rust_i18n::t;
10
11use crate::{
12    ActiveTheme as _, IconName, Root, Sizable as _, StyledExt, WindowExt as _,
13    actions::{Cancel, Confirm},
14    animation::cubic_bezier,
15    button::{Button, ButtonVariant, ButtonVariants as _},
16    h_flex,
17    scroll::ScrollableElement as _,
18    v_flex,
19};
20
21const CONTEXT: &str = "Dialog";
22pub(crate) fn init(cx: &mut App) {
23    cx.bind_keys([
24        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
25        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
26    ]);
27}
28
29type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
30type FooterFn =
31    Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
32
33/// Dialog button props.
34pub struct DialogButtonProps {
35    ok_text: Option<SharedString>,
36    ok_variant: ButtonVariant,
37    cancel_text: Option<SharedString>,
38    cancel_variant: ButtonVariant,
39}
40
41impl Default for DialogButtonProps {
42    fn default() -> Self {
43        Self {
44            ok_text: None,
45            ok_variant: ButtonVariant::Primary,
46            cancel_text: None,
47            cancel_variant: ButtonVariant::default(),
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
78/// A modal to display content in a dialog box.
79#[derive(IntoElement)]
80pub struct Dialog {
81    style: StyleRefinement,
82    title: Option<AnyElement>,
83    footer: Option<FooterFn>,
84    content: Div,
85    width: Pixels,
86    max_width: Option<Pixels>,
87    margin_top: Option<Pixels>,
88
89    on_close: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
90    on_ok: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>,
91    on_cancel: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>,
92    button_props: DialogButtonProps,
93    close_button: bool,
94    overlay: bool,
95    overlay_closable: bool,
96    keyboard: bool,
97
98    /// This will be change when open the dialog, the focus handle is create when open the dialog.
99    pub(crate) focus_handle: FocusHandle,
100    pub(crate) layer_ix: usize,
101    pub(crate) overlay_visible: bool,
102}
103
104pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
105    if !overlay {
106        return hsla(0., 0., 0., 0.);
107    }
108
109    cx.theme().overlay
110}
111
112impl Dialog {
113    /// Create a new dialog.
114    pub fn new(_: &mut Window, cx: &mut App) -> Self {
115        Self {
116            focus_handle: cx.focus_handle(),
117            style: StyleRefinement::default(),
118            title: None,
119            footer: None,
120            content: v_flex(),
121            margin_top: None,
122            width: px(480.),
123            max_width: None,
124            overlay: true,
125            keyboard: true,
126            layer_ix: 0,
127            overlay_visible: false,
128            on_close: Rc::new(|_, _, _| {}),
129            on_ok: None,
130            on_cancel: Rc::new(|_, _, _| true),
131            button_props: DialogButtonProps::default(),
132            close_button: true,
133            overlay_closable: true,
134        }
135    }
136
137    /// Sets the title of the dialog.
138    pub fn title(mut self, title: impl IntoElement) -> Self {
139        self.title = Some(title.into_any_element());
140        self
141    }
142
143    /// Set the footer of the dialog.
144    ///
145    /// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
146    ///
147    /// - First `RenderButtonFn` is the render function for the OK button.
148    /// - Second `RenderButtonFn` is the render function for the CANCEL button.
149    ///
150    /// When you set the footer, the footer will be placed default footer buttons.
151    pub fn footer<E, F>(mut self, footer: F) -> Self
152    where
153        E: IntoElement,
154        F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
155    {
156        self.footer = Some(Box::new(move |ok, cancel, window, cx| {
157            footer(ok, cancel, window, cx)
158                .into_iter()
159                .map(|e| e.into_any_element())
160                .collect()
161        }));
162        self
163    }
164
165    /// Set to use confirm dialog, with OK and Cancel buttons.
166    ///
167    /// See also [`Self::alert`]
168    pub fn confirm(self) -> Self {
169        self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)])
170            .overlay_closable(false)
171            .close_button(false)
172    }
173
174    /// Set to as a alter dialog, with OK button.
175    ///
176    /// See also [`Self::confirm`]
177    pub fn alert(self) -> Self {
178        self.footer(|ok, _, window, cx| vec![ok(window, cx)])
179            .overlay_closable(false)
180            .close_button(false)
181    }
182
183    /// Set the button props of the dialog.
184    pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
185        self.button_props = button_props;
186        self
187    }
188
189    /// Sets the callback for when the dialog is closed.
190    ///
191    /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
192    pub fn on_close(
193        mut self,
194        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
195    ) -> Self {
196        self.on_close = Rc::new(on_close);
197        self
198    }
199
200    /// Sets the callback for when the dialog is has been confirmed.
201    ///
202    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
203    pub fn on_ok(
204        mut self,
205        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
206    ) -> Self {
207        self.on_ok = Some(Rc::new(on_ok));
208        self
209    }
210
211    /// Sets the callback for when the dialog is has been canceled.
212    ///
213    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
214    pub fn on_cancel(
215        mut self,
216        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
217    ) -> Self {
218        self.on_cancel = Rc::new(on_cancel);
219        self
220    }
221
222    /// Sets the false to hide close icon, default: true
223    pub fn close_button(mut self, close_button: bool) -> Self {
224        self.close_button = close_button;
225        self
226    }
227
228    /// Set the top offset of the dialog, defaults to None, will use the 1/10 of the viewport height.
229    pub fn margin_top(mut self, margin_top: Pixels) -> Self {
230        self.margin_top = Some(margin_top);
231        self
232    }
233
234    /// Sets the width of the dialog, defaults to 480px.
235    ///
236    /// See also [`Self::width`]
237    pub fn w(mut self, width: Pixels) -> Self {
238        self.width = width;
239        self
240    }
241
242    /// Sets the width of the dialog, defaults to 480px.
243    pub fn width(mut self, width: Pixels) -> Self {
244        self.width = width;
245        self
246    }
247
248    /// Set the maximum width of the dialog, defaults to `None`.
249    pub fn max_w(mut self, max_width: Pixels) -> Self {
250        self.max_width = Some(max_width);
251        self
252    }
253
254    /// Set the overlay of the dialog, defaults to `true`.
255    pub fn overlay(mut self, overlay: bool) -> Self {
256        self.overlay = overlay;
257        self
258    }
259
260    /// Set the overlay closable of the dialog, defaults to `true`.
261    ///
262    /// When the overlay is clicked, the dialog will be closed.
263    pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
264        self.overlay_closable = overlay_closable;
265        self
266    }
267
268    /// Set whether to support keyboard esc to close the dialog, defaults to `true`.
269    pub fn keyboard(mut self, keyboard: bool) -> Self {
270        self.keyboard = keyboard;
271        self
272    }
273
274    pub(crate) fn has_overlay(&self) -> bool {
275        self.overlay
276    }
277}
278
279impl ParentElement for Dialog {
280    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
281        self.content.extend(elements);
282    }
283}
284
285impl Styled for Dialog {
286    fn style(&mut self) -> &mut gpui::StyleRefinement {
287        &mut self.style
288    }
289}
290
291impl RenderOnce for Dialog {
292    fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
293        let layer_ix = self.layer_ix;
294        let on_close = self.on_close.clone();
295        let on_ok = self.on_ok.clone();
296        let on_cancel = self.on_cancel.clone();
297
298        let render_ok: RenderButtonFn = Box::new({
299            let on_ok = on_ok.clone();
300            let on_close = on_close.clone();
301            let ok_text = self
302                .button_props
303                .ok_text
304                .unwrap_or_else(|| t!("Dialog.ok").into());
305            let ok_variant = self.button_props.ok_variant;
306            move |_, _| {
307                Button::new("ok")
308                    .label(ok_text)
309                    .with_variant(ok_variant)
310                    .on_click({
311                        let on_ok = on_ok.clone();
312                        let on_close = on_close.clone();
313
314                        move |_, window, cx| {
315                            if let Some(on_ok) = &on_ok {
316                                if !on_ok(&ClickEvent::default(), window, cx) {
317                                    return;
318                                }
319                            }
320
321                            on_close(&ClickEvent::default(), window, cx);
322                            window.close_dialog(cx);
323                        }
324                    })
325                    .into_any_element()
326            }
327        });
328        let render_cancel: RenderButtonFn = Box::new({
329            let on_cancel = on_cancel.clone();
330            let on_close = on_close.clone();
331            let cancel_text = self
332                .button_props
333                .cancel_text
334                .unwrap_or_else(|| t!("Dialog.cancel").into());
335            let cancel_variant = self.button_props.cancel_variant;
336            move |_, _| {
337                Button::new("cancel")
338                    .label(cancel_text)
339                    .with_variant(cancel_variant)
340                    .on_click({
341                        let on_cancel = on_cancel.clone();
342                        let on_close = on_close.clone();
343                        move |_, window, cx| {
344                            if !on_cancel(&ClickEvent::default(), window, cx) {
345                                return;
346                            }
347
348                            on_close(&ClickEvent::default(), window, cx);
349                            window.close_dialog(cx);
350                        }
351                    })
352                    .into_any_element()
353            }
354        });
355
356        let window_paddings = crate::window_border::window_paddings(window);
357        let view_size = window.viewport_size()
358            - gpui::size(
359                window_paddings.left + window_paddings.right,
360                window_paddings.top + window_paddings.bottom,
361            );
362        let bounds = Bounds {
363            origin: Point::default(),
364            size: view_size,
365        };
366        let offset_top = px(layer_ix as f32 * 16.);
367        let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
368        let x = bounds.center().x - self.width / 2.;
369
370        let base_size = window.text_style().font_size;
371        let rem_size = window.rem_size();
372
373        let mut paddings = Edges::all(px(24.));
374        if let Some(pl) = self.style.padding.left {
375            paddings.left = pl.to_pixels(base_size, rem_size);
376        }
377        if let Some(pr) = self.style.padding.right {
378            paddings.right = pr.to_pixels(base_size, rem_size);
379        }
380        if let Some(pt) = self.style.padding.top {
381            paddings.top = pt.to_pixels(base_size, rem_size);
382        }
383        if let Some(pb) = self.style.padding.bottom {
384            paddings.bottom = pb.to_pixels(base_size, rem_size);
385        }
386
387        let animation = Animation::new(Duration::from_secs_f64(0.25))
388            .with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
389
390        anchored()
391            .position(point(window_paddings.left, window_paddings.top))
392            .snap_to_window()
393            .child(
394                div()
395                    .id("dialog")
396                    .occlude()
397                    .w(view_size.width)
398                    .h(view_size.height)
399                    .when(self.overlay_visible, |this| {
400                        this.bg(overlay_color(self.overlay, cx))
401                    })
402                    .when(self.overlay, |this| {
403                        // Only the last dialog owns the `mouse down - close dialog` event.
404                        if (self.layer_ix + 1) != Root::read(window, cx).active_dialogs.len() {
405                            return this;
406                        }
407
408                        this.on_any_mouse_down({
409                            let on_cancel = on_cancel.clone();
410                            let on_close = on_close.clone();
411                            move |event, window, cx| {
412                                cx.stop_propagation();
413
414                                if self.overlay_closable && event.button == MouseButton::Left {
415                                    on_cancel(&ClickEvent::default(), window, cx);
416                                    on_close(&ClickEvent::default(), window, cx);
417                                    window.close_dialog(cx);
418                                }
419                            }
420                        })
421                    })
422                    .child(
423                        v_flex()
424                            .id(layer_ix)
425                            .bg(cx.theme().background)
426                            .border_1()
427                            .border_color(cx.theme().border)
428                            .rounded(cx.theme().radius_lg)
429                            .min_h_24()
430                            .pt(paddings.top)
431                            .pb(paddings.bottom)
432                            .gap(paddings.top.min(px(16.)))
433                            .refine_style(&self.style)
434                            .px_0()
435                            .key_context(CONTEXT)
436                            .track_focus(&self.focus_handle)
437                            .tab_group()
438                            .when(self.keyboard, |this| {
439                                this.on_action({
440                                    let on_cancel = on_cancel.clone();
441                                    let on_close = on_close.clone();
442                                    move |_: &Cancel, window, cx| {
443                                        // FIXME:
444                                        //
445                                        // Here some Dialog have no focus_handle, so it will not work will Escape key.
446                                        // But by now, we `cx.close_dialog()` going to close the last active model, so the Escape is unexpected to work.
447                                        on_cancel(&ClickEvent::default(), window, cx);
448                                        on_close(&ClickEvent::default(), window, cx);
449                                        window.close_dialog(cx);
450                                    }
451                                })
452                                .on_action({
453                                    let on_ok = on_ok.clone();
454                                    let on_close = on_close.clone();
455                                    let has_footer = self.footer.is_some();
456                                    move |_: &Confirm, window, cx| {
457                                        if let Some(on_ok) = &on_ok {
458                                            if on_ok(&ClickEvent::default(), window, cx) {
459                                                on_close(&ClickEvent::default(), window, cx);
460                                                window.close_dialog(cx);
461                                            }
462                                        } else if has_footer {
463                                            window.close_dialog(cx);
464                                        }
465                                    }
466                                })
467                            })
468                            // There style is high priority, can't be overridden.
469                            .absolute()
470                            .occlude()
471                            .relative()
472                            .left(x)
473                            .top(y)
474                            .w(self.width)
475                            .when_some(self.max_width, |this, w| this.max_w(w))
476                            .when_some(self.title, |this, title| {
477                                this.child(
478                                    div()
479                                        .pl(paddings.left)
480                                        .pr(paddings.right)
481                                        .line_height(relative(1.))
482                                        .font_semibold()
483                                        .child(title),
484                                )
485                            })
486                            .children(self.close_button.then(|| {
487                                let top = (paddings.top - px(10.)).max(px(8.));
488                                let right = (paddings.right - px(10.)).max(px(8.));
489
490                                Button::new("close")
491                                    .absolute()
492                                    .top(top)
493                                    .right(right)
494                                    .small()
495                                    .ghost()
496                                    .icon(IconName::Close)
497                                    .on_click({
498                                        let on_cancel = self.on_cancel.clone();
499                                        let on_close = self.on_close.clone();
500                                        move |_, window, cx| {
501                                            on_cancel(&ClickEvent::default(), window, cx);
502                                            on_close(&ClickEvent::default(), window, cx);
503                                            window.close_dialog(cx);
504                                        }
505                                    })
506                            }))
507                            .child(
508                                div().w_full().flex_1().overflow_hidden().child(
509                                    v_flex()
510                                        .id("contents")
511                                        .pl(paddings.left)
512                                        .pr(paddings.right)
513                                        .overflow_y_scrollbar()
514                                        .child(self.content),
515                                ),
516                            )
517                            .when_some(self.footer, |this, footer| {
518                                this.child(
519                                    h_flex()
520                                        .gap_2()
521                                        .pl(paddings.left)
522                                        .pr(paddings.right)
523                                        .line_height(relative(1.))
524                                        .justify_end()
525                                        .children(footer(render_ok, render_cancel, window, cx)),
526                                )
527                            })
528                            .with_animation("slide-down", animation.clone(), move |this, delta| {
529                                let y_offset = px(0.) + delta * px(30.);
530                                // This is equivalent to `shadow_xl` with an extra opacity.
531                                let shadow = vec![
532                                    BoxShadow {
533                                        color: hsla(0., 0., 0., 0.1 * delta),
534                                        offset: point(px(0.), px(20.)),
535                                        blur_radius: px(25.),
536                                        spread_radius: px(-5.),
537                                    },
538                                    BoxShadow {
539                                        color: hsla(0., 0., 0., 0.1 * delta),
540                                        offset: point(px(0.), px(8.)),
541                                        blur_radius: px(10.),
542                                        spread_radius: px(-6.),
543                                    },
544                                ];
545                                this.top(y + y_offset).shadow(shadow)
546                            }),
547                    )
548                    .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
549            )
550    }
551}