gpui_component/
dialog.rs

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