Skip to main content

glassy_ui/
dialog.rs

1use std::rc::Rc;
2
3use gpui::{
4    deferred, div, prelude::*, px, AnyElement, App, Entity, FocusHandle, FontWeight, KeyDownEvent,
5    MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
6};
7
8use crate::chrome::box_shadow;
9use crate::compat::{AccessibilityExt, Role};
10
11use crate::motion::{Motion, StyledSlot};
12use crate::theme::{paint, ActiveTheme, Theme, ThemeKind};
13
14type DialogDismissHandler = Rc<dyn Fn(&mut Window, &mut App) + 'static>;
15
16struct DialogState {
17    focus_handle: FocusHandle,
18    previous_focus: Option<FocusHandle>,
19    open: bool,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq)]
23struct DialogChrome {
24    background: gpui::Hsla,
25    border: gpui::Hsla,
26    inset: gpui::Hsla,
27    shadow: gpui::Hsla,
28    scrim: gpui::Hsla,
29}
30
31fn dialog_chrome(theme: Theme) -> DialogChrome {
32    match theme.kind {
33        ThemeKind::Light => DialogChrome {
34            background: paint(0xFFFFFF85),
35            border: paint(0xFFFFFFB8),
36            inset: paint(0xFFFFFFE6),
37            shadow: paint(0x0F172A0F),
38            scrim: paint(0x18181B47),
39        },
40        ThemeKind::Dark => DialogChrome {
41            background: paint(0xFFFFFF12),
42            border: paint(0xFFFFFF1A),
43            inset: paint(0xFFFFFF1F),
44            shadow: paint(0x00000047),
45            scrim: paint(0x00000073),
46        },
47    }
48}
49
50fn restore_focus(state: &Entity<DialogState>, window: &mut Window, cx: &mut App) {
51    let (dialog_focus, previous_focus) = {
52        let state = state.read(cx);
53        (state.focus_handle.clone(), state.previous_focus.clone())
54    };
55
56    if dialog_focus.contains_focused(window, cx) {
57        if let Some(previous_focus) = previous_focus {
58            previous_focus.focus(window);
59        }
60    }
61
62    state.update(cx, |state, cx| {
63        state.open = false;
64        state.previous_focus = None;
65        cx.notify();
66    });
67}
68
69fn dismiss_dialog(
70    state: &Entity<DialogState>,
71    on_dismiss: Option<&DialogDismissHandler>,
72    window: &mut Window,
73    cx: &mut App,
74) {
75    restore_focus(state, window, cx);
76    if let Some(on_dismiss) = on_dismiss {
77        on_dismiss(window, cx);
78    }
79}
80
81/// Controlled modal overlay with Escape, scrim dismissal, and focus restoration.
82#[derive(IntoElement)]
83pub struct Dialog {
84    id: SharedString,
85    open: bool,
86    dismiss_on_scrim: bool,
87    initial_focus: Option<FocusHandle>,
88    focus_cycle: Vec<FocusHandle>,
89    on_dismiss: Option<DialogDismissHandler>,
90    style: StyleRefinement,
91    children: Vec<AnyElement>,
92}
93
94impl Dialog {
95    pub fn new(id: impl Into<SharedString>) -> Self {
96        Self {
97            id: id.into(),
98            open: false,
99            dismiss_on_scrim: true,
100            initial_focus: None,
101            focus_cycle: Vec::new(),
102            on_dismiss: None,
103            style: StyleRefinement::default(),
104            children: Vec::new(),
105        }
106    }
107
108    pub fn open(mut self, open: bool) -> Self {
109        self.open = open;
110        self
111    }
112
113    pub fn dismiss_on_scrim(mut self, dismiss_on_scrim: bool) -> Self {
114        self.dismiss_on_scrim = dismiss_on_scrim;
115        self
116    }
117
118    pub fn initial_focus(mut self, focus_handle: FocusHandle) -> Self {
119        self.initial_focus = Some(focus_handle);
120        self
121    }
122
123    pub fn focus_cycle(mut self, focus_handles: impl IntoIterator<Item = FocusHandle>) -> Self {
124        self.focus_cycle = focus_handles.into_iter().collect();
125        self
126    }
127
128    pub fn on_dismiss(mut self, listener: impl Fn(&mut Window, &mut App) + 'static) -> Self {
129        self.on_dismiss = Some(Rc::new(listener));
130        self
131    }
132}
133
134impl Styled for Dialog {
135    fn style(&mut self) -> &mut StyleRefinement {
136        &mut self.style
137    }
138}
139
140impl ParentElement for Dialog {
141    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
142        self.children.extend(elements);
143    }
144}
145
146impl RenderOnce for Dialog {
147    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
148        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| DialogState {
149            focus_handle: cx.focus_handle().tab_stop(true),
150            previous_focus: None,
151            open: false,
152        });
153        let focus_handle = state.read(cx).focus_handle.clone();
154        let was_open = state.read(cx).open;
155
156        if self.open && !was_open {
157            let previous_focus = window.focused(cx);
158            state.update(cx, |state, _| {
159                state.open = true;
160                state.previous_focus = previous_focus;
161            });
162            self.initial_focus
163                .as_ref()
164                .unwrap_or(&focus_handle)
165                .focus(window);
166        } else if !self.open && was_open {
167            restore_focus(&state, window, cx);
168        }
169
170        if !self.open {
171            return div().into_any_element();
172        }
173
174        let chrome = dialog_chrome(cx.theme());
175        let overlay_selector = format!("{}-overlay", self.id);
176        let panel_selector = format!("{}-panel", self.id);
177        let surface_id = format!("{}-surface", self.id);
178        let overlay_id = SharedString::from(overlay_selector.clone());
179        let panel_id = SharedString::from(panel_selector.clone());
180        let scrim_state = state.clone();
181        let scrim_dismiss = self.on_dismiss.clone();
182        let keyboard_state = state.clone();
183        let keyboard_dismiss = self.on_dismiss.clone();
184        let keyboard_focus_cycle = self.focus_cycle;
185
186        let mut overlay = div()
187            .id(overlay_id)
188            .debug_selector(move || overlay_selector.clone())
189            .absolute()
190            .inset_0()
191            .size_full()
192            .flex()
193            .items_center()
194            .justify_center()
195            .bg(chrome.scrim)
196            .occlude()
197            .track_focus(&focus_handle)
198            .tab_group()
199            .tab_stop(false)
200            .refine_style(&self.style)
201            .on_key_down(move |event: &KeyDownEvent, window, cx| {
202                match event.keystroke.key.as_str() {
203                    "escape" => {
204                        dismiss_dialog(&keyboard_state, keyboard_dismiss.as_ref(), window, cx);
205                        cx.stop_propagation();
206                    }
207                    "tab" => {
208                        if keyboard_focus_cycle.is_empty() {
209                            let focus_handle = keyboard_state.read(cx).focus_handle.clone();
210                            focus_handle.focus(window);
211                        } else {
212                            let current = keyboard_focus_cycle
213                                .iter()
214                                .position(|focus_handle| focus_handle.is_focused(window));
215                            let next = if event.keystroke.modifiers.shift {
216                                current
217                                    .map(|index| {
218                                        (index + keyboard_focus_cycle.len() - 1)
219                                            % keyboard_focus_cycle.len()
220                                    })
221                                    .unwrap_or(keyboard_focus_cycle.len() - 1)
222                            } else {
223                                current
224                                    .map(|index| (index + 1) % keyboard_focus_cycle.len())
225                                    .unwrap_or(0)
226                            };
227                            keyboard_focus_cycle[next].focus(window);
228                        }
229                        cx.stop_propagation();
230                    }
231                    _ => {}
232                }
233            })
234            .child(
235                div()
236                    .id(panel_id)
237                    .debug_selector(move || panel_selector.clone())
238                    .role(Role::Dialog)
239                    .occlude()
240                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
241                    .children(self.children),
242            );
243
244        if self.dismiss_on_scrim {
245            overlay = overlay.on_mouse_down(MouseButton::Left, move |_, window, cx| {
246                dismiss_dialog(&scrim_state, scrim_dismiss.as_ref(), window, cx);
247            });
248        }
249
250        deferred(
251            Motion::new()
252                .id(surface_id)
253                .surface_in()
254                .absolute()
255                .inset_0()
256                .size_full()
257                .child(overlay),
258        )
259        .with_priority(10)
260        .into_any_element()
261    }
262}
263
264/// Radius-10 dialog panel matching the Paper material.
265#[derive(IntoElement)]
266pub struct DialogContent {
267    style: StyleRefinement,
268    children: Vec<AnyElement>,
269}
270
271impl DialogContent {
272    pub fn new() -> Self {
273        Self {
274            style: StyleRefinement::default(),
275            children: Vec::new(),
276        }
277    }
278}
279
280impl Default for DialogContent {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286impl Styled for DialogContent {
287    fn style(&mut self) -> &mut StyleRefinement {
288        &mut self.style
289    }
290}
291
292impl ParentElement for DialogContent {
293    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
294        self.children.extend(elements);
295    }
296}
297
298impl RenderOnce for DialogContent {
299    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
300        let chrome = dialog_chrome(cx.theme());
301
302        div()
303            .flex()
304            .flex_col()
305            .w(px(400.))
306            .flex_shrink_0()
307            .p(px(24.))
308            .gap(px(16.))
309            .rounded(px(10.))
310            .border_1()
311            .border_color(chrome.border)
312            .bg(chrome.background)
313            .shadow(vec![
314                box_shadow(0., 1., chrome.inset, 0., 0.),
315                box_shadow(0., 6., chrome.shadow, 16., 0.),
316            ])
317            .refine_style(&self.style)
318            .children(self.children)
319    }
320}
321
322/// Groups a dialog title and supporting description.
323#[derive(IntoElement)]
324pub struct DialogHeader {
325    style: StyleRefinement,
326    children: Vec<AnyElement>,
327}
328
329impl DialogHeader {
330    pub fn new() -> Self {
331        Self {
332            style: StyleRefinement::default(),
333            children: Vec::new(),
334        }
335    }
336}
337
338impl Default for DialogHeader {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344impl Styled for DialogHeader {
345    fn style(&mut self) -> &mut StyleRefinement {
346        &mut self.style
347    }
348}
349
350impl ParentElement for DialogHeader {
351    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
352        self.children.extend(elements);
353    }
354}
355
356impl RenderOnce for DialogHeader {
357    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
358        div()
359            .flex()
360            .flex_col()
361            .gap(px(8.))
362            .refine_style(&self.style)
363            .children(self.children)
364    }
365}
366
367/// Dialog heading text.
368#[derive(IntoElement)]
369pub struct DialogTitle {
370    text: SharedString,
371    style: StyleRefinement,
372}
373
374impl DialogTitle {
375    pub fn new(text: impl Into<SharedString>) -> Self {
376        Self {
377            text: text.into(),
378            style: StyleRefinement::default(),
379        }
380    }
381}
382
383impl Styled for DialogTitle {
384    fn style(&mut self) -> &mut StyleRefinement {
385        &mut self.style
386    }
387}
388
389impl RenderOnce for DialogTitle {
390    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
391        let theme = cx.theme();
392        div()
393            .font_family(theme.font_family)
394            .font_weight(FontWeight::SEMIBOLD)
395            .text_size(px(16.))
396            .line_height(px(22.))
397            .text_color(theme.heading)
398            .refine_style(&self.style)
399            .child(self.text)
400    }
401}
402
403/// Supporting copy beneath a dialog title.
404#[derive(IntoElement)]
405pub struct DialogDescription {
406    text: SharedString,
407    style: StyleRefinement,
408}
409
410impl DialogDescription {
411    pub fn new(text: impl Into<SharedString>) -> Self {
412        Self {
413            text: text.into(),
414            style: StyleRefinement::default(),
415        }
416    }
417}
418
419impl Styled for DialogDescription {
420    fn style(&mut self) -> &mut StyleRefinement {
421        &mut self.style
422    }
423}
424
425impl RenderOnce for DialogDescription {
426    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
427        let theme = cx.theme();
428        div()
429            .font_family(theme.font_family)
430            .font_weight(FontWeight::NORMAL)
431            .text_size(px(15.))
432            .line_height(px(24.))
433            .text_color(theme.body)
434            .refine_style(&self.style)
435            .child(self.text)
436    }
437}
438
439/// Right-aligned dialog action row.
440#[derive(IntoElement)]
441pub struct DialogFooter {
442    style: StyleRefinement,
443    children: Vec<AnyElement>,
444}
445
446impl DialogFooter {
447    pub fn new() -> Self {
448        Self {
449            style: StyleRefinement::default(),
450            children: Vec::new(),
451        }
452    }
453}
454
455impl Default for DialogFooter {
456    fn default() -> Self {
457        Self::new()
458    }
459}
460
461impl Styled for DialogFooter {
462    fn style(&mut self) -> &mut StyleRefinement {
463        &mut self.style
464    }
465}
466
467impl ParentElement for DialogFooter {
468    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
469        self.children.extend(elements);
470    }
471}
472
473impl RenderOnce for DialogFooter {
474    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
475        div()
476            .flex()
477            .items_center()
478            .justify_end()
479            .gap(px(8.))
480            .refine_style(&self.style)
481            .children(self.children)
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn light_material_matches_paper() {
491        let chrome = dialog_chrome(Theme::light());
492        assert_eq!(chrome.background, paint(0xFFFFFF85));
493        assert_eq!(chrome.border, paint(0xFFFFFFB8));
494        assert_eq!(chrome.inset, paint(0xFFFFFFE6));
495        assert_eq!(chrome.shadow, paint(0x0F172A0F));
496        assert_eq!(chrome.scrim, paint(0x18181B47));
497    }
498
499    #[test]
500    fn dark_material_matches_paper() {
501        let chrome = dialog_chrome(Theme::dark());
502        assert_eq!(chrome.background, paint(0xFFFFFF12));
503        assert_eq!(chrome.border, paint(0xFFFFFF1A));
504        assert_eq!(chrome.inset, paint(0xFFFFFF1F));
505        assert_eq!(chrome.shadow, paint(0x00000047));
506        assert_eq!(chrome.scrim, paint(0x00000073));
507    }
508}