Skip to main content

gpui_cn/
dialog.rs

1//! A modal window over the page, as shadcn's `Dialog`.
2
3use std::rc::Rc;
4
5use gpui_kit::{
6    AnyElement, App, ElementId, Entity, FocusHandle, FontWeight, InteractiveElement as _,
7    IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window,
8    assets::IconName,
9    base::{self, StyledExt as _, TestSupportExt as _, actions::Cancel, h_flex, v_flex},
10    div,
11};
12
13use crate::{
14    ActiveTheme as _, Button, ButtonSize, Icon,
15    menu::{MenuLook, MenuMotion},
16};
17
18type OpenChange = Rc<dyn Fn(bool, &mut Window, &mut App)>;
19
20/// Where a dialog's focus lives: the surface that traps it, and the
21/// element that had it before the dialog opened.
22struct Focus {
23    surface: FocusHandle,
24    previous: Option<FocusHandle>,
25    opened: bool,
26}
27
28/// A modal window over the page, as shadcn's `Dialog`: a dimmed backdrop
29/// and a centered surface with a title, a description, a body, and a
30/// footer.
31///
32/// `gpui-base` owns the behavior: the dialog is a modal layer that traps
33/// focus, and Escape or a press on the backdrop asks to close it. This
34/// component draws it as a menu's panel: the popover surface, its
35/// hairline, the large radius, and the shadow, with the theme's dialog
36/// width and padding. Focus moves into the dialog when it opens, to the
37/// element given to [`track_focus`](Self::track_focus) or the surface,
38/// and goes back to where it was when the dialog closes.
39///
40/// ```
41/// use gpui_cn::{Button, Dialog};
42/// use gpui_kit::{IntoElement, ParentElement as _, div};
43///
44/// fn confirm(open: bool) -> impl IntoElement {
45///     Dialog::new("confirm")
46///         .open(open)
47///         .title("Delete the file?")
48///         .description("This cannot be undone.")
49///         .footer(Button::new("confirm-ok").primary().label("Delete"))
50///         .child(div().child("report.pdf"))
51/// }
52/// ```
53///
54/// The application owns the open state: [`open`](Self::open) sets it, and
55/// [`on_open_change`](Self::on_open_change) reports Escape, a backdrop
56/// press, and the close button, which should set it. The id must be
57/// stable across frames.
58///
59/// `Styled` refines the surface after the tokens, so `.w(..)` sets the
60/// width.
61#[derive(IntoElement)]
62pub struct Dialog {
63    id: ElementId,
64    open: bool,
65    title: Option<SharedString>,
66    description: Option<SharedString>,
67    children: Vec<AnyElement>,
68    footer: Option<AnyElement>,
69    show_close_button: bool,
70    on_open_change: Option<OpenChange>,
71    focus: Option<FocusHandle>,
72    top: Option<Pixels>,
73    style: StyleRefinement,
74}
75
76impl Dialog {
77    /// A closed dialog with a stable id.
78    pub fn new(id: impl Into<ElementId>) -> Self {
79        Self {
80            id: id.into(),
81            open: false,
82            title: None,
83            description: None,
84            children: Vec::new(),
85            footer: None,
86            show_close_button: true,
87            on_open_change: None,
88            focus: None,
89            top: None,
90            style: StyleRefinement::default(),
91        }
92    }
93
94    /// Shows or hides the dialog.
95    pub fn open(mut self, open: bool) -> Self {
96        self.open = open;
97        self
98    }
99
100    /// Called when Escape, a press on the backdrop, or the close button
101    /// asks to close the dialog. It gets `false`.
102    pub fn on_open_change(
103        mut self,
104        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
105    ) -> Self {
106        self.on_open_change = Some(Rc::new(handler));
107        self
108    }
109
110    /// The title, in the theme's large semibold text.
111    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
112        self.title = Some(title.into());
113        self
114    }
115
116    /// The description under the title, in muted text.
117    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
118        self.description = Some(description.into());
119        self
120    }
121
122    /// The row of actions under the body, lined up to the trailing edge.
123    pub fn footer(mut self, footer: impl IntoElement) -> Self {
124        self.footer = Some(footer.into_any_element());
125        self
126    }
127
128    /// Whether the close button shows in the top corner. On by default.
129    pub fn show_close_button(mut self, show: bool) -> Self {
130        self.show_close_button = show;
131        self
132    }
133
134    /// Places the surface this far below the top edge of the window, in
135    /// place of the vertical center.
136    pub(crate) fn top(mut self, offset: Pixels) -> Self {
137        self.top = Some(offset);
138        self
139    }
140
141    /// The element that takes focus when the dialog opens, such as a
142    /// field in it. Without one the surface takes it.
143    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
144        self.focus = Some(focus.clone());
145        self
146    }
147}
148
149impl Styled for Dialog {
150    fn style(&mut self) -> &mut StyleRefinement {
151        &mut self.style
152    }
153}
154
155impl ParentElement for Dialog {
156    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
157        self.children.extend(elements);
158    }
159}
160
161impl RenderOnce for Dialog {
162    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
163        let child = |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
164        let focus: Entity<Focus> = window.use_keyed_state(child("focus"), cx, |_, cx| Focus {
165            surface: cx.focus_handle(),
166            previous: None,
167            opened: false,
168        });
169        let surface = focus.read(cx).surface.clone();
170        move_focus(&focus, self.open, self.focus.as_ref(), window, cx);
171
172        let theme = cx.theme();
173        let look = MenuLook::of(theme, window.rem_size());
174        let overlay = theme.dialog_overlay;
175        let width = theme.metrics.dialog_width;
176        let padding = theme.metrics.dialog_padding;
177        let title_style = theme.base.typography.lg;
178        let Some(motion) =
179            MenuMotion::sample(child("presence"), self.open, false, &look, window, cx)
180        else {
181            return div().into_any_element();
182        };
183
184        let close = self.show_close_button.then(|| {
185            let target = surface.clone();
186            div().absolute().top_3().right_3().child(
187                Button::new(child("close"))
188                    .ghost()
189                    .size(ButtonSize::Sm)
190                    .icon(Icon::from(IconName::Close))
191                    .accessibility_label("Close")
192                    .on_click(move |_, window, cx| {
193                        target.dispatch_action(&Cancel, window, cx);
194                    }),
195            )
196        });
197        let header = (self.title.is_some() || self.description.is_some()).then(|| {
198            v_flex()
199                .gap_2()
200                .children(self.title.map(|title| {
201                    div()
202                        .text_size(title_style.size)
203                        .line_height(title_style.line_height)
204                        .font_weight(FontWeight::SEMIBOLD)
205                        .child(title)
206                }))
207                .children(
208                    self.description
209                        .map(|text| div().text_color(look.muted_foreground).child(text)),
210                )
211        });
212        let popup = v_flex()
213            .id(child("popup"))
214            .test_support()
215            .relative()
216            .occlude()
217            .w(width)
218            .max_w_full()
219            .gap_4()
220            .p(padding)
221            .rounded(look.radius)
222            .bg(look.surface)
223            .border_1()
224            .border_color(look.border)
225            .shadow(look.shadow(motion.shadow_strength))
226            .opacity(motion.opacity)
227            .text_size(look.text_size)
228            .line_height(look.line_height)
229            .text_color(look.foreground)
230            .refine_style(&self.style)
231            .children(close)
232            .children(header)
233            .children(self.children)
234            .children(
235                self.footer
236                    .map(|footer| h_flex().justify_end().gap_2().child(footer)),
237            );
238
239        let on_open_change = self.on_open_change;
240        let mut dialog = base::Dialog::new(cx)
241            .focus_handle(surface)
242            .open(true)
243            .backdrop(
244                div()
245                    .id(child("backdrop"))
246                    .test_support()
247                    .absolute()
248                    .size_full()
249                    .bg(overlay.opacity(motion.opacity)),
250            )
251            .popup(popup)
252            .p_4();
253        if let Some(offset) = self.top {
254            dialog = dialog.items_start().pt(offset);
255        }
256        if let Some(handler) = on_open_change {
257            dialog = dialog.on_open_change(move |open, _, window, cx| handler(open, window, cx));
258        }
259        dialog.into_any_element()
260    }
261}
262
263/// Moves focus into the dialog as it opens, and back to what had it as it
264/// closes, once each. The trap's own focus goes to the surface unless the
265/// dialog names an element.
266fn move_focus(
267    focus: &Entity<Focus>,
268    open: bool,
269    target: Option<&FocusHandle>,
270    window: &mut Window,
271    cx: &mut App,
272) {
273    let (surface, previous, opened) = {
274        let focus = focus.read(cx);
275        (focus.surface.clone(), focus.previous.clone(), focus.opened)
276    };
277    if open && !opened {
278        let before = window.focused(cx);
279        target.unwrap_or(&surface).focus(window, cx);
280        focus.update(cx, |focus, _| {
281            focus.previous = before;
282            focus.opened = true;
283        });
284    } else if !open && opened {
285        if surface.contains_focused(window, cx)
286            && let Some(previous) = previous
287        {
288            previous.focus(window, cx);
289        }
290        focus.update(cx, |focus, _| {
291            focus.previous = None;
292            focus.opened = false;
293        });
294    }
295}