Skip to main content

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}