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