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#[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#[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#[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#[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#[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#[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}