Skip to main content

gpui_kit/overlay/
dialog.rs

1//! A modal that asks one question and reports the answer.
2//!
3//! Open state and the element that had the keyboard before opening both
4//! outlive a frame, so a dialog is a view rather than a builder. The body is
5//! a callback instead of a stored element because an `AnyElement` can be
6//! consumed once, while the dialog re-renders for as long as it stays open.
7
8use std::rc::Rc;
9
10use gpui::{
11    AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
12    IntoElement, KeyDownEvent, ParentElement, Render, SharedString, Styled, Window, div, px,
13};
14use gpui_kit_semantics::{NodeSpec, Role, Semantic};
15use gpui_kit_theme::{ActiveTheme, Elevation, Space};
16
17use crate::controls::button::{Button, ButtonVariant};
18use crate::foundation::{Ident, StyledExt};
19use crate::overlay::focus::FocusTrap;
20use crate::overlay::layer::{Overlay, surface};
21use crate::overlay::panel::{self, Body};
22
23/// What the dialog reports. The owner decides what any of it means.
24///
25/// An outcome is always followed by [`DialogEvent::Closed`], so a subscriber
26/// that only cares that the dialog went away has one event to watch.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DialogEvent {
29    Opened,
30    /// The primary action was taken.
31    Confirmed,
32    /// The cancel action was taken.
33    Cancelled,
34    /// The dialog was waved away, by escape or by the scrim.
35    Dismissed,
36    Closed,
37}
38
39impl EventEmitter<DialogEvent> for Dialog {}
40
41/// A composed modal: scrim, focus trap, title, body, and up to two actions.
42pub struct Dialog {
43    ident: Ident,
44    focus_handle: FocusHandle,
45    confirm_focus: FocusHandle,
46    cancel_focus: FocusHandle,
47    title: SharedString,
48    description: Option<SharedString>,
49    body: Option<Body>,
50    confirm_label: Option<SharedString>,
51    cancel_label: Option<SharedString>,
52    dismissable: bool,
53    destructive: bool,
54    open: bool,
55    /// Set by `open`, cleared by the first frame that can act on it.
56    pending_focus: bool,
57    trap: FocusTrap,
58}
59
60impl std::fmt::Debug for Dialog {
61    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        formatter
63            .debug_struct("Dialog")
64            .field("ident", &self.ident)
65            .field("title", &self.title)
66            .field("has_body", &self.body.is_some())
67            .field("dismissable", &self.dismissable)
68            .field("destructive", &self.destructive)
69            .field("open", &self.open)
70            .finish()
71    }
72}
73
74impl Dialog {
75    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
76        Self {
77            ident: ident.into(),
78            focus_handle: cx.focus_handle(),
79            confirm_focus: cx.focus_handle(),
80            cancel_focus: cx.focus_handle(),
81            title: SharedString::default(),
82            description: None,
83            body: None,
84            confirm_label: None,
85            cancel_label: None,
86            dismissable: true,
87            destructive: false,
88            open: false,
89            pending_focus: false,
90            trap: FocusTrap::new(),
91        }
92    }
93
94    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
95        self.title = title.into();
96        self
97    }
98
99    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
100        self.description = Some(description.into());
101        self
102    }
103
104    /// Supplies the body, rebuilt on every frame the dialog is open.
105    pub fn content(mut self, body: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self {
106        self.body = Some(Rc::new(body));
107        self
108    }
109
110    /// Whether escape and the scrim close the dialog. A dialog that is not
111    /// dismissable installs neither handler.
112    pub fn dismissable(mut self, dismissable: bool) -> Self {
113        self.dismissable = dismissable;
114        self
115    }
116
117    /// Marks the primary action as one that destroys something.
118    pub fn destructive(mut self, destructive: bool) -> Self {
119        self.destructive = destructive;
120        self
121    }
122
123    pub fn confirm_label(mut self, label: impl Into<SharedString>) -> Self {
124        self.confirm_label = Some(label.into());
125        self
126    }
127
128    pub fn cancel_label(mut self, label: impl Into<SharedString>) -> Self {
129        self.cancel_label = Some(label.into());
130        self
131    }
132
133    pub fn is_open(&self) -> bool {
134        self.open
135    }
136
137    pub fn is_dismissable(&self) -> bool {
138        self.dismissable
139    }
140
141    pub fn set_title(&mut self, title: impl Into<SharedString>, cx: &mut Context<Self>) {
142        self.title = title.into();
143        cx.notify();
144    }
145
146    pub fn set_description(&mut self, description: Option<SharedString>, cx: &mut Context<Self>) {
147        self.description = description;
148        cx.notify();
149    }
150
151    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
152        if self.open {
153            return;
154        }
155        self.open = true;
156        self.pending_focus = true;
157        self.trap.engage(window, cx);
158        cx.emit(DialogEvent::Opened);
159        cx.notify();
160    }
161
162    /// Closes without an outcome. The window is required because closing gives
163    /// the keyboard back to whatever held it before the dialog opened.
164    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
165        if !self.open {
166            return;
167        }
168        self.open = false;
169        self.pending_focus = false;
170        self.trap.release(window, cx);
171        cx.emit(DialogEvent::Closed);
172        cx.notify();
173    }
174
175    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
176        if !self.open {
177            return;
178        }
179        cx.emit(DialogEvent::Confirmed);
180        self.close(window, cx);
181    }
182
183    pub fn cancel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
184        if !self.open {
185            return;
186        }
187        cx.emit(DialogEvent::Cancelled);
188        self.close(window, cx);
189    }
190
191    /// Reports a wave-away. A dialog that is not dismissable cannot be waved
192    /// away even by a host calling this directly.
193    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
194        if !self.open || !self.dismissable {
195            return;
196        }
197        cx.emit(DialogEvent::Dismissed);
198        self.close(window, cx);
199    }
200
201    /// Where the keyboard lands when the dialog opens.
202    ///
203    /// A destructive confirmation opens on cancel, so a stray return key does
204    /// not destroy anything.
205    fn initial_focus(&self) -> FocusHandle {
206        if self.destructive && self.cancel_label.is_some() {
207            return self.cancel_focus.clone();
208        }
209        if self.confirm_label.is_some() {
210            return self.confirm_focus.clone();
211        }
212        if self.cancel_label.is_some() {
213            return self.cancel_focus.clone();
214        }
215        self.focus_handle.clone()
216    }
217
218    fn on_navigation_key(
219        &mut self,
220        event: &KeyDownEvent,
221        window: &mut Window,
222        cx: &mut Context<Self>,
223    ) {
224        if !self.open || event.keystroke.key.as_str() != "tab" {
225            return;
226        }
227        if event.keystroke.modifiers.shift {
228            self.trap.focus_prev(window, cx);
229        } else {
230            self.trap.focus_next(window, cx);
231        }
232        cx.stop_propagation();
233    }
234
235    fn on_dismiss_key(
236        &mut self,
237        event: &KeyDownEvent,
238        window: &mut Window,
239        cx: &mut Context<Self>,
240    ) {
241        if !self.open || event.keystroke.key.as_str() != "escape" {
242            return;
243        }
244        self.dismiss(window, cx);
245        cx.stop_propagation();
246    }
247
248    fn actions(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
249        if self.confirm_label.is_none() && self.cancel_label.is_none() {
250            return None;
251        }
252        let theme = cx.theme().clone();
253        let dialog = cx.entity().downgrade();
254        let cancel = self.cancel_label.clone().map(|label| {
255            let dialog = dialog.clone();
256            Button::new(self.ident.child("cancel"))
257                .label(label)
258                .secondary()
259                .track_focus(&self.cancel_focus)
260                .on_click(move |window, cx| {
261                    dialog
262                        .update(cx, |dialog, cx| dialog.cancel(window, cx))
263                        .ok();
264                })
265        });
266        let confirm = self.confirm_label.clone().map(|label| {
267            let dialog = dialog.clone();
268            Button::new(self.ident.child("confirm"))
269                .label(label)
270                .variant(if self.destructive {
271                    ButtonVariant::Danger
272                } else {
273                    ButtonVariant::Primary
274                })
275                .track_focus(&self.confirm_focus)
276                .on_click(move |window, cx| {
277                    dialog
278                        .update(cx, |dialog, cx| dialog.confirm(window, cx))
279                        .ok();
280                })
281        });
282
283        Some(
284            div()
285                .row()
286                .justify_end()
287                .gap_token(&theme, Space::Sm)
288                .children(cancel)
289                .children(confirm)
290                .into_any_element(),
291        )
292    }
293}
294
295impl Focusable for Dialog {
296    fn focus_handle(&self, _cx: &App) -> FocusHandle {
297        self.focus_handle.clone()
298    }
299}
300
301impl Render for Dialog {
302    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
303        self.trap.begin_frame();
304        if !self.open {
305            return div().into_any_element();
306        }
307
308        if self.cancel_label.is_some() {
309            self.trap.register(self.cancel_focus.clone());
310        }
311        if self.confirm_label.is_some() {
312            self.trap.register(self.confirm_focus.clone());
313        }
314        if self.trap.stops().is_empty() {
315            self.trap.register(self.focus_handle.clone());
316        }
317        if self.pending_focus {
318            // The handle can only take focus once this frame has put it in the
319            // dispatch tree, which is why opening only records the intent.
320            self.pending_focus = false;
321            self.initial_focus().focus(window, cx);
322        }
323
324        let theme = cx.theme().clone();
325        let title = self.title.clone();
326        let description = self.description.clone();
327        let body = self.body.clone().map(|body| body(window, cx));
328        let actions = self.actions(cx);
329
330        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Dialog)
331            .modal(true)
332            .focus(&self.focus_handle);
333        if !title.is_empty() {
334            spec = spec.text(title.clone());
335        }
336        if let Some(description) = description.clone() {
337            spec = spec.description(description);
338        }
339
340        let heading = (!title.is_empty()).then(|| panel::heading(&self.ident, &theme, title, cx));
341        let description =
342            description.map(|description| panel::description(&self.ident, &theme, description, cx));
343
344        let mut card = surface(&theme, Elevation::Modal)
345            .w(px(360.0))
346            .p_token(&theme, Space::Lg)
347            .gap_token(&theme, Space::Sm)
348            .track_focus(&self.focus_handle)
349            .on_key_down(cx.listener(Self::on_navigation_key));
350        if self.dismissable {
351            card = card.on_key_down(cx.listener(Self::on_dismiss_key));
352        }
353        let card = card
354            .children(heading)
355            .children(description)
356            .children(body)
357            .children(actions)
358            .semantic_in(cx, spec);
359
360        let mut overlay = Overlay::modal(self.ident.child("overlay")).child(card);
361        if self.dismissable {
362            let dialog = cx.entity().downgrade();
363            overlay = overlay.on_dismiss(move |window, cx| {
364                dialog
365                    .update(cx, |dialog, cx| dialog.dismiss(window, cx))
366                    .ok();
367            });
368        }
369        overlay.into_any_element()
370    }
371}