gpuikit/elements/dialog.rs
1//! Dialog component for modal interactions
2//!
3//! Provides a modal overlay with a semi-transparent backdrop, centered content panel,
4//! and configurable close behavior (Escape key, backdrop click).
5//!
6//! # Example
7//!
8//! ```
9//! # use gpui::{Context, IntoElement, Render, Window, div, prelude::*};
10//! use gpuikit::elements::button::button;
11//! use gpuikit::elements::dialog::{DialogState, dialog};
12//! use gpuikit::layout::h_stack;
13//! # struct D;
14//! # impl Render for D { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
15//! let dialog_state = cx.new(|_cx| DialogState::new(
16//! dialog("confirm-dialog")
17//! .title("Are you sure?")
18//! .description("This action cannot be undone.")
19//! .content(|window, cx| {
20//! div().child("Custom body content here").into_any_element()
21//! })
22//! .footer(|window, cx| {
23//! h_stack()
24//! .gap_2()
25//! .child(button("cancel", "Cancel"))
26//! .child(button("confirm", "Confirm"))
27//! .into_any_element()
28//! })
29//! ));
30//!
31//! // Open the dialog
32//! dialog_state.update(cx, |state, cx| state.open(window, cx));
33//! # dialog_state
34//! # }}
35//! # let mut tcx = gpui::TestAppContext::single();
36//! # tcx.update(gpuikit::init);
37//! # let _ = tcx.add_window_view(|_, _| D);
38//! ```
39
40use crate::a11y::{A11y, Announce};
41use crate::element_id::scoped;
42use crate::elements::button::button;
43use crate::elements::icon_button::icon_button;
44use crate::icons::Icons;
45use crate::layout::h_stack;
46use crate::theme::{ActiveTheme, ControlSize, Themeable};
47use crate::traits::accessible::Accessible;
48use crate::traits::control_sized::ControlSized;
49use gpui::{
50 AnyElement, App, Context, DismissEvent, ElementId, EventEmitter, FocusHandle, Focusable,
51 IntoElement, KeyBinding, ParentElement, Render, Role, SharedString, Styled, Window, actions,
52 deferred, div, prelude::*, px,
53};
54use std::rc::Rc;
55
56actions!(dialog, [Close]);
57
58/// The key context used for dialog keybindings.
59pub const DIALOG_CONTEXT: &str = "Dialog";
60
61/// Event emitted when the dialog is opened.
62pub struct DialogOpened;
63
64/// Event emitted when the dialog is closed.
65pub struct DialogClosed;
66
67/// Event emitted when a confirmation dialog's confirming answer was chosen.
68///
69/// Only [`DialogState::confirm`] emits this, and only the confirm button calls
70/// it. Escape, the backdrop and the header's close button all route through
71/// `dismiss`, so there is no path in this module from a stray key or click to
72/// a confirmation.
73pub struct DialogConfirmed;
74
75/// Event emitted when a confirmation dialog was answered "no" — by the cancel
76/// button, by Escape, or by a click on the backdrop.
77pub struct DialogCancelled;
78
79/// The confirmation half of a [`Dialog`]: a question, and two answers of which
80/// one destroys something.
81///
82/// Built by [`Dialog::confirm`], which takes the question and its detail
83/// together — that pairing is the point. Everywhere else in the builder the
84/// title and description are independent `Option`s, and a confirmation with
85/// one and not the other is a dialog that either asks nothing or explains
86/// nothing. The refining builders ([`Dialog::confirm_label`] and friends)
87/// deliberately **refuse** to create one, so `.confirm_label("Delete")` on a
88/// plain dialog cannot conjure an alert with no question in it.
89///
90/// # `destructive` defaults to `true`
91///
92/// A caller who has not thought about it gets the louder answer rather than
93/// two identical buttons, because the dialog this element exists for is the
94/// one guarding something unrecoverable. That default has a cost worth
95/// knowing: a library whose every confirmation is red teaches people that red
96/// means nothing. A confirmation that merely needs acknowledging — "Save
97/// changes?", "Continue?" — should say `Dialog::destructive(false)`, and
98/// that is what the flag is for.
99#[derive(Clone)]
100pub struct Confirmation {
101 confirm_label: SharedString,
102 cancel_label: SharedString,
103 destructive: bool,
104 on_confirm: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
105 on_cancel: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
106}
107
108impl Default for Confirmation {
109 fn default() -> Self {
110 Self {
111 // "Confirm" is the fallback, not the recommendation: name the verb
112 // — "Delete", "Discard", "Revoke" — so the answer is readable
113 // without the question.
114 confirm_label: "Confirm".into(),
115 cancel_label: "Cancel".into(),
116 destructive: true,
117 on_confirm: None,
118 on_cancel: None,
119 }
120 }
121}
122
123/// Builder for creating a dialog component.
124///
125/// Use the [`dialog`] function to create an instance.
126pub struct Dialog {
127 id: ElementId,
128 title: Option<SharedString>,
129 description: Option<SharedString>,
130 content: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
131 footer: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
132 close_on_escape: bool,
133 close_on_backdrop_click: bool,
134 show_close_button: bool,
135 confirmation: Option<Confirmation>,
136 size: ControlSize,
137}
138
139/// Creates a new dialog builder.
140///
141/// # Arguments
142///
143/// * `id` - Unique identifier for the dialog
144pub fn dialog(id: impl Into<ElementId>) -> Dialog {
145 Dialog::new(id)
146}
147
148impl Dialog {
149 /// Create a new dialog builder.
150 pub fn new(id: impl Into<ElementId>) -> Self {
151 Self {
152 id: id.into(),
153 title: None,
154 description: None,
155 content: None,
156 footer: None,
157 close_on_escape: true,
158 close_on_backdrop_click: true,
159 show_close_button: true,
160 confirmation: None,
161 size: ControlSize::default(),
162 }
163 }
164
165 /// Turn this into a **confirmation**: a question, its consequence, and two
166 /// answers.
167 ///
168 /// One call, because the question and the detail are a pair — see
169 /// [`Confirmation`]. It also turns the header's close button off: Cancel
170 /// is already on screen, and two ways to say no in the same corner is one
171 /// too many.
172 ///
173 /// ```
174 /// # use gpui::{Context, IntoElement, Render, Window, prelude::*};
175 /// use gpuikit::elements::dialog::{DialogState, dialog};
176 /// # struct D;
177 /// # impl Render for D { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
178 /// # cx.new(|_cx| DialogState::new(
179 /// dialog("delete-project")
180 /// .confirm("Delete this project?", "Its 42 tasks are deleted with it. This cannot be undone.")
181 /// .confirm_label("Delete")
182 /// .on_confirm(|_window, _cx| { /* … */ })
183 /// # ))}}
184 /// # let mut tcx = gpui::TestAppContext::single();
185 /// # tcx.update(gpuikit::init);
186 /// # let _ = tcx.add_window_view(|_, _| D);
187 /// ```
188 pub fn confirm(
189 mut self,
190 question: impl Into<SharedString>,
191 consequence: impl Into<SharedString>,
192 ) -> Self {
193 self.title = Some(question.into());
194 self.description = Some(consequence.into());
195 self.confirmation = Some(self.confirmation.take().unwrap_or_default());
196 self.show_close_button = false;
197 self
198 }
199
200 /// Refine the confirmation, or complain in a debug build that there is
201 /// none to refine.
202 ///
203 /// This is what keeps `.confirm_label("Delete")` from producing an alert
204 /// dialog with no question in it: the refining builders can only ever
205 /// adjust a confirmation [`Dialog::confirm`] already made.
206 fn with_confirmation(mut self, refine: impl FnOnce(&mut Confirmation)) -> Self {
207 debug_assert!(
208 self.confirmation.is_some(),
209 "this builder refines a confirmation and does not create one — call \
210 `.confirm(question, consequence)` first, so the dialog has something to \
211 confirm"
212 );
213 if let Some(confirmation) = self.confirmation.as_mut() {
214 refine(confirmation);
215 }
216 self
217 }
218
219 /// Label the confirming answer. Defaults to "Confirm"; name the verb.
220 pub fn confirm_label(self, label: impl Into<SharedString>) -> Self {
221 let label = label.into();
222 self.with_confirmation(|confirmation| confirmation.confirm_label = label)
223 }
224
225 /// Label the safe answer. Defaults to "Cancel".
226 pub fn cancel_label(self, label: impl Into<SharedString>) -> Self {
227 let label = label.into();
228 self.with_confirmation(|confirmation| confirmation.cancel_label = label)
229 }
230
231 /// Whether the confirming answer is styled as destructive. Defaults to
232 /// `true` — see [`Confirmation`] for the argument, and for why a
233 /// confirmation that merely needs acknowledging should say `false`.
234 pub fn destructive(self, destructive: bool) -> Self {
235 self.with_confirmation(|confirmation| confirmation.destructive = destructive)
236 }
237
238 /// What to run when the confirming answer is chosen.
239 pub fn on_confirm(self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
240 let handler = Rc::new(handler);
241 self.with_confirmation(|confirmation| confirmation.on_confirm = Some(handler))
242 }
243
244 /// What to run when the dialog is cancelled — by the button, by Escape, or
245 /// by the backdrop.
246 pub fn on_cancel(self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
247 let handler = Rc::new(handler);
248 self.with_confirmation(|confirmation| confirmation.on_cancel = Some(handler))
249 }
250
251 /// Set the dialog title.
252 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
253 self.title = Some(title.into());
254 self
255 }
256
257 /// Set the dialog description.
258 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
259 self.description = Some(description.into());
260 self
261 }
262
263 /// Set arbitrary body content rendered between description and footer.
264 pub fn content(
265 mut self,
266 content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
267 ) -> Self {
268 self.content = Some(Rc::new(content));
269 self
270 }
271
272 /// Set the footer content (typically action buttons).
273 pub fn footer(
274 mut self,
275 footer: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
276 ) -> Self {
277 self.footer = Some(Rc::new(footer));
278 self
279 }
280
281 /// Configure whether pressing Escape closes the dialog.
282 ///
283 /// Passing `false` to a **confirmation** dialog voids the "Escape cancels"
284 /// promise this module's docs make for it: the Escape handler gates on this
285 /// flag, so Escape becomes inert and the only way out is a footer button.
286 /// Turn it off for a plain dialog if you like; leave it on for a
287 /// confirmation.
288 pub fn close_on_escape(mut self, close: bool) -> Self {
289 self.close_on_escape = close;
290 self
291 }
292
293 /// Configure whether clicking the backdrop closes the dialog.
294 pub fn close_on_backdrop_click(mut self, close: bool) -> Self {
295 self.close_on_backdrop_click = close;
296 self
297 }
298
299 /// Configure whether to show the close button in the header.
300 pub fn show_close_button(mut self, show: bool) -> Self {
301 self.show_close_button = show;
302 self
303 }
304}
305
306/// The rung sizes the two action buttons of a confirmation and the gap between
307/// them, and nothing else.
308///
309/// A dialog is not a row control: taken literally, "every dimension from the
310/// rung" would give a modal surface a 20px height. The panel's own padding
311/// stays component-specific, which is what `src/theme/control.rs`'s "What
312/// belongs here" note asks for.
313impl ControlSized for Dialog {
314 fn control_size(mut self, size: ControlSize) -> Self {
315 self.size = size;
316 self
317 }
318}
319
320/// Stateful dialog component that manages open/close state.
321///
322/// Create using [`Dialog`] and wrap in an Entity:
323///
324/// ```
325/// # use gpui::{Context, IntoElement, Render, Window, prelude::*};
326/// use gpuikit::elements::dialog::{DialogState, dialog};
327/// # struct D;
328/// # impl Render for D { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
329/// let state = cx.new(|_cx| DialogState::new(dialog("my-dialog").title("Hello")));
330/// # state
331/// # }}
332/// # let mut tcx = gpui::TestAppContext::single();
333/// # tcx.update(gpuikit::init);
334/// # let _ = tcx.add_window_view(|_, _| D);
335/// ```
336pub struct DialogState {
337 id: ElementId,
338 title: Option<SharedString>,
339 description: Option<SharedString>,
340 content: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
341 footer: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
342 close_on_escape: bool,
343 close_on_backdrop_click: bool,
344 show_close_button: bool,
345 confirmation: Option<Confirmation>,
346 size: ControlSize,
347 is_open: bool,
348 focus_handle: Option<FocusHandle>,
349 /// The safe answer's handle, minted alongside the root's and focused after
350 /// it, so a confirmation opens on Cancel rather than on Delete.
351 cancel_focus_handle: Option<FocusHandle>,
352}
353
354impl EventEmitter<DialogOpened> for DialogState {}
355impl EventEmitter<DialogClosed> for DialogState {}
356impl EventEmitter<DialogConfirmed> for DialogState {}
357impl EventEmitter<DialogCancelled> for DialogState {}
358impl EventEmitter<DismissEvent> for DialogState {}
359
360impl DialogState {
361 /// Create a new dialog state from a Dialog builder.
362 pub fn new(dialog: Dialog) -> Self {
363 Self {
364 id: dialog.id,
365 title: dialog.title,
366 description: dialog.description,
367 content: dialog.content,
368 footer: dialog.footer,
369 close_on_escape: dialog.close_on_escape,
370 close_on_backdrop_click: dialog.close_on_backdrop_click,
371 show_close_button: dialog.show_close_button,
372 confirmation: dialog.confirmation,
373 size: dialog.size,
374 is_open: false,
375 focus_handle: None,
376 cancel_focus_handle: None,
377 }
378 }
379
380 /// Check if the dialog is currently open.
381 pub fn is_open(&self) -> bool {
382 self.is_open
383 }
384
385 /// Whether this dialog asks a question with two answers.
386 pub fn is_confirmation(&self) -> bool {
387 self.confirmation.is_some()
388 }
389
390 /// Open the dialog.
391 pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
392 if self.is_open {
393 return;
394 }
395
396 self.is_open = true;
397 let focus_handle = cx.focus_handle();
398 // The root is focused first and tracked by the element below, because
399 // it is what carries `DIALOG_CONTEXT` and the `Close` action. Focus
400 // then moves down to the safe answer, which is a descendant of that
401 // element — so Escape still dispatches to this view.
402 window.focus(&focus_handle, cx);
403 self.focus_handle = Some(focus_handle);
404
405 if self.is_confirmation() {
406 let cancel = cx.focus_handle();
407 window.focus(&cancel, cx);
408 self.cancel_focus_handle = Some(cancel);
409 }
410
411 cx.emit(DialogOpened);
412 cx.notify();
413 }
414
415 /// Close the dialog.
416 pub fn close(&mut self, cx: &mut Context<Self>) {
417 if !self.is_open {
418 return;
419 }
420
421 self.is_open = false;
422 self.focus_handle = None;
423 self.cancel_focus_handle = None;
424 cx.emit(DialogClosed);
425 cx.emit(DismissEvent);
426 cx.notify();
427 }
428
429 /// Toggle the dialog open/closed state.
430 pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
431 if self.is_open {
432 self.close(cx);
433 } else {
434 self.open(window, cx);
435 }
436 }
437
438 /// Choose the confirming answer: close, emit [`DialogConfirmed`], run the
439 /// handler.
440 ///
441 /// A no-op on a closed dialog and on a dialog that is not a confirmation,
442 /// so a second press of a button that is no longer on screen cannot run
443 /// the handler twice.
444 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
445 if !self.is_open {
446 return;
447 }
448 let Some(handler) = self
449 .confirmation
450 .as_ref()
451 .map(|confirmation| confirmation.on_confirm.clone())
452 else {
453 return;
454 };
455
456 self.close(cx);
457 cx.emit(DialogConfirmed);
458 if let Some(handler) = handler {
459 handler(window, cx);
460 }
461 }
462
463 /// Choose the safe answer: close, emit [`DialogCancelled`], run the
464 /// handler. A no-op on a closed dialog.
465 pub fn cancel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
466 if !self.is_open {
467 return;
468 }
469 let handler = self
470 .confirmation
471 .as_ref()
472 .and_then(|confirmation| confirmation.on_cancel.clone());
473
474 self.close(cx);
475 cx.emit(DialogCancelled);
476 if let Some(handler) = handler {
477 handler(window, cx);
478 }
479 }
480
481 /// The one way out that is not the confirm button.
482 ///
483 /// Escape, the backdrop and the header's close button all come through
484 /// here: a confirmation is cancelled, a plain dialog is closed. Keeping
485 /// them on one path is what guarantees there is no route from a key or a
486 /// stray click to [`DialogConfirmed`].
487 fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
488 if self.is_confirmation() {
489 self.cancel(window, cx);
490 } else {
491 self.close(cx);
492 }
493 }
494
495 fn handle_close(&mut self, _: &Close, window: &mut Window, cx: &mut Context<Self>) {
496 if self.close_on_escape {
497 self.dismiss(window, cx);
498 }
499 }
500}
501
502/// A confirmation announces `Role::AlertDialog`, named by its question and
503/// described by its consequence. A plain dialog announces **nothing** — see
504/// the note on [`Render for DialogState`](DialogState#impl-Render-for-DialogState)
505/// below and the paragraph in `render`.
506impl Accessible for DialogState {
507 fn a11y(&self) -> A11y {
508 let role = if self.is_confirmation() {
509 Role::AlertDialog
510 } else {
511 Role::Dialog
512 };
513
514 let mut a11y = A11y::new(role);
515 if let Some(title) = self.title.clone() {
516 a11y = a11y.name(title);
517 }
518 if let Some(description) = self.description.clone() {
519 a11y = a11y.description(description);
520 }
521 a11y
522 }
523}
524
525impl Focusable for DialogState {
526 fn focus_handle(&self, cx: &App) -> FocusHandle {
527 self.focus_handle
528 .clone()
529 .unwrap_or_else(|| cx.focus_handle())
530 }
531}
532
533impl Render for DialogState {
534 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
535 let theme = cx.theme();
536
537 if !self.is_open {
538 return div().into_any_element();
539 }
540
541 let focus_handle = self.focus_handle.clone();
542 let cancel_focus_handle = self.cancel_focus_handle.clone();
543 let close_on_backdrop_click = self.close_on_backdrop_click;
544 let show_close_button = self.show_close_button;
545 let title = self.title.clone();
546 let description = self.description.clone();
547 let content = self.content.clone();
548 let confirmation = self.confirmation.clone();
549 // A caller-supplied footer is *ignored* in confirm mode rather than
550 // stacked under the two answers: a confirmation with three buttons is
551 // no longer a confirmation.
552 let footer = self.footer.clone().filter(|_| confirmation.is_none());
553 let size = self.size;
554 let footer_gap = theme.control(size).gap;
555
556 // What the panel announces, and why a plain dialog announces nothing.
557 //
558 // `ELEMENTS_WITHOUT_A_ROLE` used to excuse this module with: *"would be
559 // Role::Dialog with a required name and `modal`; gpui has no
560 // `aria_modal` builder, and a dialog that announces itself unmodal is
561 // worse than one that waits."* gpui still has no `aria_modal` — the
562 // `aria_*` family on `Div` is label, description, keyshortcuts,
563 // active_descendant, selected, expanded, toggled, the numeric-value
564 // family, orientation, level, position_in_set, size_of_set and the
565 // row/column family, and nothing may reach around `A11y` to fake one.
566 //
567 // So that sentence is answered for one half and left standing for the
568 // other. A **confirmation** announces `Role::AlertDialog` anyway: an
569 // alert that a screen reader hears about, unmodal, conveys more than
570 // silence, and the affordance is the whole reason this mode exists. A
571 // **plain dialog** still waits — the recorded reason applies to it
572 // unchanged, and announcing an unmodal `Role::Dialog` is exactly what
573 // this module wrote down as worse than waiting. When `aria_modal`
574 // lands upstream, the guard below is the one line to delete.
575 let announcement = confirmation.is_some().then(|| self.a11y());
576
577 let backdrop_color = theme.overlay();
578 let surface_color = theme.surface();
579 let border_color = theme.border();
580 let fg_color = theme.fg();
581 let fg_muted_color = theme.fg_muted();
582
583 // Render the dialog overlay using deferred() for proper layering
584 deferred(
585 div()
586 .id(self.id.clone())
587 .key_context(DIALOG_CONTEXT)
588 .when_some(focus_handle, |this, handle| {
589 this.track_focus(&handle)
590 .on_action(cx.listener(Self::handle_close))
591 })
592 // Full-screen backdrop using absolute positioning
593 .absolute()
594 .top_0()
595 .left_0()
596 .right_0()
597 .bottom_0()
598 .size_full()
599 .flex()
600 .items_center()
601 .justify_center()
602 .bg(backdrop_color)
603 // Handle backdrop click
604 .when(close_on_backdrop_click, |this| {
605 this.on_mouse_down(
606 gpui::MouseButton::Left,
607 cx.listener(|this, _event, window, cx| {
608 // This will be prevented from closing if clicking on the panel.
609 // `dismiss`, not `close`: a backdrop click on a
610 // confirmation is a "no", not a shrug.
611 this.dismiss(window, cx);
612 }),
613 )
614 })
615 // Dialog panel
616 .child(
617 div()
618 // Was unique only because it sits under the backdrop's
619 // `.id(self.id)`; it derives from that id directly now.
620 .id(scoped(&self.id, "panel"))
621 // On the panel, not the scrim: the panel is what the
622 // title names.
623 .when_some(announcement, |this, a11y| this.announce(a11y))
624 // Prevent backdrop click from closing when clicking on panel
625 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
626 cx.stop_propagation();
627 })
628 .min_w(px(320.))
629 .max_w(px(500.))
630 .bg(surface_color)
631 .border_1()
632 .border_color(border_color)
633 .rounded_lg()
634 .shadow_xl()
635 .flex()
636 .flex_col()
637 // Header
638 .when(title.is_some() || show_close_button, |this| {
639 this.child(
640 div()
641 .flex()
642 .items_start()
643 .justify_between()
644 .p_4()
645 .when(
646 description.is_some()
647 || content.is_some()
648 || footer.is_some(),
649 |this| this.pb_0(),
650 )
651 // Title
652 .when_some(title.clone(), |this, title| {
653 this.child(
654 div()
655 .flex_1()
656 .text_base()
657 .font_weight(gpui::FontWeight::SEMIBOLD)
658 .text_color(fg_color)
659 .child(title),
660 )
661 })
662 // Close button
663 .when(show_close_button, |this| {
664 this.child(
665 icon_button(
666 scoped(&self.id, "close"),
667 Icons::cross_1(),
668 )
669 .on_click(
670 cx.listener(|this, _, window, cx| {
671 this.dismiss(window, cx);
672 }),
673 ),
674 )
675 }),
676 )
677 })
678 // Description
679 .when_some(description, |this, desc| {
680 this.child(
681 div()
682 .px_4()
683 .pt_2()
684 .when(
685 content.is_none()
686 && footer.is_none()
687 && confirmation.is_none(),
688 |this| this.pb_4(),
689 )
690 .text_sm()
691 .text_color(fg_muted_color)
692 .child(desc),
693 )
694 })
695 // Content
696 .when_some(content, |this, content| {
697 this.child(
698 div()
699 .px_4()
700 .pt_2()
701 .when(footer.is_none() && confirmation.is_none(), |this| {
702 this.pb_4()
703 })
704 .child(content(window, cx)),
705 )
706 })
707 // Footer
708 .when_some(footer, |this, footer| {
709 this.child(
710 div()
711 .flex()
712 .justify_end()
713 .gap_2()
714 .p_4()
715 .child(footer(window, cx)),
716 )
717 })
718 // The confirmation's own footer: the safe answer
719 // first, and the only route in this module to
720 // `DialogConfirmed`.
721 .when_some(confirmation, |this, confirmation| {
722 this.child(
723 h_stack()
724 .justify_end()
725 .gap(footer_gap)
726 .p_4()
727 .child(
728 button(
729 scoped(&self.id, "cancel"),
730 confirmation.cancel_label.clone(),
731 )
732 .control_size(size)
733 .when_some(cancel_focus_handle, |cancel, handle| {
734 cancel.focus_handle(handle)
735 })
736 .on_click(
737 cx.listener(|this, _, window, cx| {
738 this.cancel(window, cx);
739 }),
740 ),
741 )
742 .child(
743 button(
744 scoped(&self.id, "confirm"),
745 confirmation.confirm_label.clone(),
746 )
747 .control_size(size)
748 .when(confirmation.destructive, |confirm| {
749 confirm.destructive()
750 })
751 .on_click(
752 cx.listener(|this, _, window, cx| {
753 this.confirm(window, cx);
754 }),
755 ),
756 ),
757 )
758 }),
759 ),
760 )
761 .with_priority(10)
762 .into_any_element()
763 }
764}
765
766/// Binds the dialog keybindings to the application.
767///
768/// Call this in your application's initialization to enable escape-to-close functionality.
769///
770/// # Example
771///
772/// ```no_run
773/// # use gpui::Application;
774/// use gpuikit::elements::dialog::bind_dialog_keys;
775///
776/// fn main() {
777/// Application::with_platform(gpui_platform::current_platform(false)).run(|cx| {
778/// bind_dialog_keys(cx);
779/// // ... rest of app initialization
780/// });
781/// }
782/// ```
783pub fn bind_dialog_keys(cx: &mut App) {
784 cx.bind_keys([KeyBinding::new("escape", Close, Some(DIALOG_CONTEXT))]);
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use gpui::TestAppContext;
791 use std::cell::RefCell;
792
793 fn confirmation() -> Dialog {
794 dialog("delete-project").confirm(
795 "Delete this project?",
796 "Its 42 tasks are deleted with it. This cannot be undone.",
797 )
798 }
799
800 /// The whole announcement, compared as one value.
801 ///
802 /// `A11y` exposes no description getter but does derive `PartialEq`, so
803 /// comparing the built value is how the description is pinned at all.
804 #[test]
805 fn a_confirmation_announces_an_alert_dialog_named_by_its_question() {
806 let state = DialogState::new(confirmation());
807
808 assert!(state.is_confirmation());
809 assert_eq!(
810 state.a11y(),
811 A11y::new(Role::AlertDialog)
812 .name("Delete this project?")
813 .description("Its 42 tasks are deleted with it. This cannot be undone.")
814 );
815 }
816
817 /// A plain dialog is `Role::Dialog` — and is deliberately **not**
818 /// announced, which is the half of `ELEMENTS_WITHOUT_A_ROLE`'s deleted
819 /// reason that still stands: gpui has no `aria_modal`, and an unmodal
820 /// `Role::Dialog` is what this crate wrote down as worse than waiting.
821 /// `render` guards the announcement on there being a confirmation.
822 #[test]
823 fn a_plain_dialog_is_not_announced() {
824 let state = DialogState::new(dialog("plain").title("Settings"));
825
826 assert!(!state.is_confirmation());
827 assert_eq!(state.a11y(), A11y::new(Role::Dialog).name("Settings"));
828 }
829
830 /// The guard is not dead code: an untitled dialog has nothing to announce,
831 /// and `role_requires_a_name` covers `Role::Dialog`, so announcing it
832 /// would trip `announce`'s own `debug_assert!`.
833 #[test]
834 fn an_untitled_dialog_has_no_name_to_announce() {
835 assert!(
836 DialogState::new(dialog("plain"))
837 .a11y()
838 .is_missing_a_required_name()
839 );
840 }
841
842 /// The refining builders adjust a confirmation and refuse to create one,
843 /// so `.confirm_label("Delete")` alone cannot produce an alert dialog with
844 /// no question in it.
845 #[test]
846 #[should_panic(expected = "refines a confirmation and does not create one")]
847 fn a_label_builder_does_not_conjure_a_confirmation() {
848 let _ = dialog("plain").title("Settings").confirm_label("Delete");
849 }
850
851 #[test]
852 fn the_confirming_answer_is_destructive_unless_told_otherwise() {
853 let loud = DialogState::new(confirmation());
854 assert!(loud.confirmation.as_ref().unwrap().destructive);
855
856 let quiet = DialogState::new(confirmation().destructive(false));
857 assert!(!quiet.confirmation.as_ref().unwrap().destructive);
858 }
859
860 #[test]
861 fn a_destructive_button_reports_its_variant() {
862 use crate::elements::button::{ButtonVariant, button};
863
864 // `b.variant()` with no argument resolves to the inherent *builder*
865 // and fails to compile; the getter is reached through the trait.
866 let plain = button("save", "Save");
867 assert_eq!(
868 crate::traits::button::Button::variant(&plain),
869 ButtonVariant::Filled
870 );
871 assert_eq!(
872 crate::traits::button::Button::variant(&button("delete", "Delete").destructive()),
873 ButtonVariant::Destructive
874 );
875 }
876
877 /// Escape's route, taken as the action handler gpui dispatches `Close` to.
878 ///
879 /// A confirmation is *cancelled*, never confirmed. There is no path in
880 /// this module from a key to `DialogConfirmed`: `handle_close`, the
881 /// backdrop listener and the header's close button all call `dismiss`, and
882 /// only the confirm button calls `confirm`.
883 #[gpui::test]
884 fn escape_cancels_a_confirmation_and_never_confirms_it(cx: &mut TestAppContext) {
885 // `add_window` renders, and `render` reads the theme.
886 cx.update(crate::theme::init);
887
888 let confirmed = Rc::new(RefCell::new(0usize));
889 let cancelled = Rc::new(RefCell::new(0usize));
890 let (yes, no) = (confirmed.clone(), cancelled.clone());
891
892 let window = cx.add_window(move |_window, _cx| {
893 DialogState::new(
894 confirmation()
895 .on_confirm(move |_, _| *yes.borrow_mut() += 1)
896 .on_cancel(move |_, _| *no.borrow_mut() += 1),
897 )
898 });
899
900 window
901 .update(cx, |state, window, cx| {
902 state.open(window, cx);
903 assert!(state.is_open());
904 state.handle_close(&Close, window, cx);
905 })
906 .unwrap();
907
908 assert_eq!(*confirmed.borrow(), 0, "Escape confirmed a destruction");
909 assert_eq!(*cancelled.borrow(), 1);
910 window
911 .update(cx, |state, _window, _cx| assert!(!state.is_open()))
912 .unwrap();
913 }
914
915 /// `dismiss` is the one way out that is not the confirm button, and it
916 /// plainly closes a dialog that has nothing to cancel.
917 #[gpui::test]
918 fn dismissing_a_plain_dialog_just_closes_it(cx: &mut TestAppContext) {
919 // `add_window` renders, and `render` reads the theme.
920 cx.update(crate::theme::init);
921
922 let window =
923 cx.add_window(|_window, _cx| DialogState::new(dialog("plain").title("Settings")));
924
925 window
926 .update(cx, |state, window, cx| {
927 state.open(window, cx);
928 state.dismiss(window, cx);
929 assert!(!state.is_open());
930 })
931 .unwrap();
932 }
933
934 #[gpui::test]
935 fn confirming_runs_its_handler_exactly_once(cx: &mut TestAppContext) {
936 // `add_window` renders, and `render` reads the theme.
937 cx.update(crate::theme::init);
938
939 let ran = Rc::new(RefCell::new(0usize));
940 let counter = ran.clone();
941
942 let window = cx.add_window(move |_window, _cx| {
943 DialogState::new(confirmation().on_confirm(move |_, _| *counter.borrow_mut() += 1))
944 });
945
946 window
947 .update(cx, |state, window, cx| {
948 state.open(window, cx);
949 state.confirm(window, cx);
950 // The dialog is off screen by now, but a second press of a
951 // button that has not been re-rendered yet must not run the
952 // handler again.
953 state.confirm(window, cx);
954 assert!(!state.is_open());
955 })
956 .unwrap();
957
958 assert_eq!(*ran.borrow(), 1);
959 }
960
961 /// The property this feature exists for: a destructive question opens on
962 /// the safe answer.
963 #[gpui::test]
964 fn a_confirmation_opens_with_the_safe_action_focused(cx: &mut TestAppContext) {
965 // `add_window` renders, and `render` reads the theme.
966 cx.update(crate::theme::init);
967
968 let window = cx.add_window(|_window, _cx| DialogState::new(confirmation()));
969
970 window
971 .update(cx, |state, window, cx| {
972 state.open(window, cx);
973
974 let cancel = state
975 .cancel_focus_handle
976 .clone()
977 .expect("a confirmation mints a handle for its safe answer");
978 assert_eq!(
979 window.focused(cx),
980 Some(cancel),
981 "a confirmation opened on something other than Cancel"
982 );
983 })
984 .unwrap();
985 }
986
987 /// A plain dialog mints no second handle, so nothing is competing with the
988 /// root for focus.
989 #[gpui::test]
990 fn a_plain_dialog_focuses_only_its_root(cx: &mut TestAppContext) {
991 // `add_window` renders, and `render` reads the theme.
992 cx.update(crate::theme::init);
993
994 let window =
995 cx.add_window(|_window, _cx| DialogState::new(dialog("plain").title("Settings")));
996
997 window
998 .update(cx, |state, window, cx| {
999 state.open(window, cx);
1000 assert!(state.cancel_focus_handle.is_none());
1001 assert_eq!(window.focused(cx), state.focus_handle.clone());
1002 })
1003 .unwrap();
1004 }
1005}