Skip to main content

gpui_component/dialog/
alert_dialog.rs

1use gpui::{
2    AnyElement, App, ClickEvent, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
3    StyleRefinement, Styled, Window, prelude::FluentBuilder as _,
4};
5
6use crate::{
7    StyledExt as _, WindowExt as _,
8    button::ButtonVariant,
9    dialog::{
10        Dialog, DialogButtonProps, DialogDescription, DialogEntrance, DialogFooter, DialogHeader,
11        DialogTitle,
12    },
13    h_flex, v_flex,
14};
15
16/// AlertDialog is a modal dialog that interrupts the user with important content
17/// and expects a response.
18///
19/// It is built on top of the Dialog component with opinionated defaults:
20/// - Footer buttons are center-aligned (vs right-aligned in Dialog)
21/// - Icon is optional (disabled by default, enable with `.show_icon(true)`)
22/// - Simplified API for common alert scenarios
23/// - Uses declarative DialogHeader, DialogTitle, DialogDescription, and DialogFooter components
24/// - Supports both imperative and declarative API styles
25///
26/// # Examples
27///
28/// ## Imperative API (using WindowExt)
29///
30/// ```ignore
31/// use gpui_kit::component::{AlertDialog, alert::AlertVariant};
32///
33/// // Using WindowExt trait
34/// window.open_alert_dialog(cx, |alert, _, _| {
35///     alert
36///         .title("Unsaved Changes")
37///         .description("You have unsaved changes. Are you sure you want to leave?")
38///         .show_cancel(true)
39/// });
40/// ```
41///
42/// ## Declarative API (using trigger and content)
43///
44/// ```ignore
45/// use gpui_kit::component::{AlertDialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter};
46///
47/// AlertDialog::new(cx)
48///     .trigger(Button::new("delete").label("Delete"))
49///     .content(|content, _, cx| {
50///         content
51///             .child(
52///                 DialogHeader::new()
53///                     .items_center()
54///                     .child(DialogTitle::new().child("Delete File"))
55///                     .child(DialogDescription::new().child("Are you sure?"))
56///             )
57///             .child(
58///                 DialogFooter::new()
59///                     .justify_center()
60///                     .child(Button::new("cancel").label("Cancel"))
61///                     .child(Button::new("confirm").label("Delete"))
62///             )
63///     })
64/// ```
65#[derive(IntoElement)]
66pub struct AlertDialog {
67    base: Dialog,
68    trigger: Option<AnyElement>,
69    icon: Option<AnyElement>,
70    title: Option<AnyElement>,
71    description: Option<AnyElement>,
72    children: Vec<AnyElement>,
73}
74
75impl AlertDialog {
76    /// Create a new AlertDialog.
77    ///
78    /// By default, the dialog is not overlay closable with a OK button.
79    ///
80    pub fn new(cx: &mut App) -> Self {
81        Self {
82            base: Dialog::new(cx)
83                .with_base_alert_dialog(gpui_base::AlertDialog::new(cx))
84                .close_button(false),
85            trigger: None,
86            icon: None,
87            title: None,
88            description: None,
89            children: Vec::new(),
90        }
91    }
92
93    /// Set to use confirm dialog, with OK and Cancel buttons.
94    ///
95    /// The default of [`AlertDialog`] has OK button.
96    pub fn confirm(mut self) -> Self {
97        self.base.button_props.show_cancel = Some(true);
98        self
99    }
100
101    /// Sets the trigger element for the alert dialog.
102    ///
103    /// When a trigger is set, the dialog will render as a trigger element that opens the dialog when clicked.
104    ///
105    /// **Note**: When using `.trigger()`, you should also use `.content()` to define the dialog content
106    /// declaratively instead of using `.title()`, `.description()`, etc.
107    ///
108    /// The `title`, `description`, `icon`, and `button_props` will be ignored when used together with `.trigger()`.
109    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
110        self.trigger = Some(trigger.into_any_element());
111        self
112    }
113
114    /// Sets the content builder for declarative API.
115    ///
116    /// When using this method, you define the dialog content using declarative components like
117    /// `DialogHeader`, `DialogTitle`, `DialogDescription`, and `DialogFooter`.
118    ///
119    /// This method is typically used together with `.trigger()` for a fully declarative API.
120    ///
121    /// # Examples
122    ///
123    /// ```ignore
124    /// AlertDialog::new(cx)
125    ///     .trigger(Button::new("delete").label("Delete"))
126    ///     .content(|content, _, cx| {
127    ///         content
128    ///             .child(DialogHeader::new().child(DialogTitle::new().child("Confirm")))
129    ///             .child(DialogFooter::new().child(Button::new("ok").label("OK")))
130    ///     })
131    /// ```
132    pub fn content<F>(mut self, builder: F) -> Self
133    where
134        F: Fn(crate::dialog::DialogContent, &mut Window, &mut App) -> crate::dialog::DialogContent
135            + 'static,
136    {
137        self.base = self.base.content(builder);
138        self
139    }
140
141    /// Sets the footer builder for declarative API.
142    ///
143    /// This is used to define the footer content using declarative components like `DialogFooter`.
144    ///
145    /// If not set, a default footer with OK and optional Cancel button will be used.
146    pub fn footer(mut self, footer: impl IntoElement) -> Self {
147        self.base = self.base.footer(footer);
148        self
149    }
150
151    #[track_caller]
152    fn debug_assert_no_trigger(&self) {
153        debug_assert!(
154            self.trigger.is_none() && self.base.content_builder.is_none(),
155            "Cannot set this property when trigger is used. Use content() to define dialog content instead."
156        );
157    }
158
159    /// Sets the icon of the alert dialog, default is None.
160    #[track_caller]
161    pub fn icon(mut self, icon: impl IntoElement) -> Self {
162        self.debug_assert_no_trigger();
163        self.icon = Some(icon.into_any_element());
164        self
165    }
166
167    /// Sets the title of the alert dialog.
168    #[track_caller]
169    pub fn title(mut self, title: impl IntoElement) -> Self {
170        self.debug_assert_no_trigger();
171        self.title = Some(title.into_any_element());
172        self
173    }
174
175    /// Sets the description of the alert dialog.
176    #[track_caller]
177    pub fn description(mut self, description: impl IntoElement) -> Self {
178        self.debug_assert_no_trigger();
179        self.description = Some(description.into_any_element());
180        self
181    }
182
183    /// Set the button props of the alert dialog.
184    ///
185    /// Use this to configure button text, variants, and visibility in one
186    /// value. It overrides only the fields `button_props` sets, so the Cancel
187    /// button [`Self::confirm`] asked for and callbacks set earlier survive,
188    /// whatever the call order. For a single property, prefer the direct
189    /// builders — [`Self::ok_text`], [`Self::ok_variant`],
190    /// [`Self::cancel_text`], [`Self::cancel_variant`].
191    ///
192    /// # Examples
193    ///
194    /// ```ignore
195    /// alert.confirm().button_props(
196    ///     DialogButtonProps::default()
197    ///         .ok_text("Delete")
198    ///         .ok_variant(ButtonVariant::Danger)
199    ///         .cancel_text("Keep")
200    /// )
201    /// ```
202    #[track_caller]
203    pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
204        self.debug_assert_no_trigger();
205        self.base = self.base.button_props(button_props);
206        self
207    }
208
209    /// Sets the text of the OK button. Default is `OK`.
210    pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
211        self.base.button_props.ok_text = Some(ok_text.into());
212        self
213    }
214
215    /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
216    pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
217        self.base.button_props.ok_variant = Some(ok_variant);
218        self
219    }
220
221    /// Sets the text of the Cancel button. Default is `Cancel`.
222    pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
223        self.base.button_props.cancel_text = Some(cancel_text.into());
224        self
225    }
226
227    /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`.
228    pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
229        self.base.button_props.cancel_variant = Some(cancel_variant);
230        self
231    }
232
233    /// Sets the width of the alert dialog, defaults to 420px.
234    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
235        self.base = self.base.width(width);
236        self
237    }
238
239    /// Show cancel button. Default is false.
240    pub fn show_cancel(mut self, show_cancel: bool) -> Self {
241        self.base.button_props.show_cancel = Some(show_cancel);
242        self
243    }
244
245    /// Alert dialogs never close from a backdrop press.
246    #[deprecated(note = "AlertDialog backdrop dismissal is disabled by design")]
247    pub fn overlay_closable(self, _: bool) -> Self {
248        self
249    }
250
251    /// Set the close button of the alert dialog, defaults to `false`.
252    pub fn close_button(mut self, close_button: bool) -> Self {
253        self.base = self.base.close_button(close_button);
254        self
255    }
256
257    /// Set whether to support keyboard esc to close the dialog, defaults to `true`.
258    pub fn keyboard(mut self, keyboard: bool) -> Self {
259        self.base = self.base.keyboard(keyboard);
260        self
261    }
262
263    /// Sets the top offset of the alert dialog.
264    ///
265    /// Without this, the surface keeps the [`Dialog`] default: a tenth of the
266    /// viewport height below the top edge, which pins short alerts near the
267    /// top of a tall window. Pass a larger offset to seat an alert further
268    /// down, e.g. in the vertical middle of the window.
269    pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
270        self.base = self.base.margin_top(margin_top);
271        self
272    }
273
274    /// Centers the alert dialog vertically in the window, whatever its
275    /// height, in place of the [`Self::margin_top`] offset.
276    pub fn centered(mut self) -> Self {
277        self.base = self.base.centered();
278        self
279    }
280
281    /// Sets how the alert dialog enters, defaulting to [`DialogEntrance::SlideDown`].
282    ///
283    /// Shares the surface, backdrop, and reduced-motion policy of [`Dialog::entrance`].
284    pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
285        self.base = self.base.entrance(entrance);
286        self
287    }
288
289    /// Sets the callback for when the alert dialog is closed.
290    ///
291    /// Called after [`Self::on_action`] or [`Self::on_cancel`] callback.
292    pub fn on_close(
293        mut self,
294        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
295    ) -> Self {
296        self.base = self.base.on_close(on_close);
297        self
298    }
299
300    /// Sets the callback for when the OK/action button is clicked.
301    ///
302    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
303    pub fn on_ok(
304        mut self,
305        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
306    ) -> Self {
307        self.base = self.base.on_ok(on_ok);
308        self
309    }
310
311    /// Sets the callback for when the alert dialog has been canceled.
312    ///
313    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
314    pub fn on_cancel(
315        mut self,
316        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
317    ) -> Self {
318        self.base = self.base.on_cancel(on_cancel);
319        self
320    }
321
322    /// Build the styled dialog surface around the Base alert-dialog host.
323    pub(crate) fn build_surface(self, window: &mut Window, cx: &mut App) -> Dialog {
324        let button_props = self.base.button_props.clone();
325        let has_title = self.icon.is_some() || self.title.is_some();
326        let has_header = has_title || self.description.is_some();
327        let has_footer = self.base.footer.is_some();
328
329        self.base
330            .when(has_header, |this| {
331                this.header(
332                    DialogHeader::new().child(
333                        h_flex()
334                            .gap_2()
335                            .items_start()
336                            .when_some(self.icon, |row, icon| row.child(icon))
337                            .child(
338                                v_flex()
339                                    .flex_1()
340                                    .min_w_0()
341                                    .gap_1()
342                                    .when_some(self.title, |this, title| {
343                                        this.child(DialogTitle::new().child(title))
344                                    })
345                                    .when_some(self.description, |this, desc| {
346                                        this.child(DialogDescription::new().child(desc))
347                                    }),
348                            ),
349                    ),
350                )
351            })
352            .children(self.children)
353            .when(!has_footer, |this| {
354                // Default footer for AlertDialog if user doesn't provide one, with OK and optional Cancel button
355                this.footer(
356                    DialogFooter::new()
357                        .when(button_props.is_cancel_shown(), |this| {
358                            this.child(button_props.render_cancel(window, cx))
359                        })
360                        .child(button_props.render_ok(window, cx)),
361                )
362            })
363    }
364}
365
366impl Styled for AlertDialog {
367    fn style(&mut self) -> &mut StyleRefinement {
368        &mut self.base.style
369    }
370}
371
372impl ParentElement for AlertDialog {
373    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
374        self.children.extend(elements);
375    }
376}
377
378impl AlertDialog {
379    fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
380        let content_builder = self.base.content_builder.clone();
381        let style = self.base.style.clone();
382        let props = self.base.props.clone();
383        let button_props = self.base.button_props.clone();
384
385        gpui_base::AlertDialogTrigger::new(trigger)
386            .on_open(move |window, cx| {
387                let content_builder = content_builder.clone();
388                let style = style.clone();
389                let props = props.clone();
390                let button_props = button_props.clone();
391                window.open_dialog(cx, move |dialog, _, cx| {
392                    dialog
393                        .with_base_alert_dialog(gpui_base::AlertDialog::new(cx))
394                        .refine_style(&style)
395                        .button_props(button_props.clone())
396                        .with_props(props.clone())
397                        .when_some(content_builder.clone(), |this, content_builder| {
398                            this.content(move |content, window, cx| {
399                                content_builder(content, window, cx)
400                            })
401                        })
402                });
403            })
404            .into_any_element()
405    }
406}
407
408impl RenderOnce for AlertDialog {
409    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
410        if let Some(trigger) = self.trigger.take() {
411            // If a trigger is provided, render the trigger element that opens the dialog
412            self.render_trigger(trigger, window, cx)
413        } else {
414            // Otherwise, render the dialog content directly
415            self.build_surface(window, cx).into_any_element()
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use std::{cell::Cell, rc::Rc};
423
424    use gpui::{ClickEvent, TestAppContext, px, size};
425
426    use super::*;
427    use crate::dialog::dialog::tests::window;
428
429    /// `button_props` overrides only the fields it sets, so the Cancel button
430    /// `confirm` asked for survives a later props value that never mentions it.
431    #[gpui::test]
432    fn button_props_after_confirm_keeps_the_cancel_button(cx: &mut TestAppContext) {
433        let cx = window(cx, size(px(800.), px(600.)));
434        cx.update(|_, cx| {
435            let alert = AlertDialog::new(cx)
436                .confirm()
437                .button_props(DialogButtonProps::default().ok_text("Delete"));
438
439            assert!(alert.base.button_props.is_cancel_shown());
440            assert_eq!(alert.base.button_props.ok_text.as_deref(), Some("Delete"));
441        });
442    }
443
444    /// The call order does not matter either way round.
445    #[gpui::test]
446    fn confirm_after_button_props_keeps_the_ok_text(cx: &mut TestAppContext) {
447        let cx = window(cx, size(px(800.), px(600.)));
448        cx.update(|_, cx| {
449            let alert = AlertDialog::new(cx)
450                .button_props(DialogButtonProps::default().ok_text("Delete"))
451                .confirm();
452
453            assert!(alert.base.button_props.is_cancel_shown());
454            assert_eq!(alert.base.button_props.ok_text.as_deref(), Some("Delete"));
455        });
456    }
457
458    /// A callback installed with `on_ok` survives a later props value that
459    /// carries no callback of its own.
460    #[gpui::test]
461    fn button_props_after_on_ok_keeps_the_callback(cx: &mut TestAppContext) {
462        let cx = window(cx, size(px(800.), px(600.)));
463        let confirmed = Rc::new(Cell::new(0));
464        let counter = confirmed.clone();
465        cx.update(|window, cx| {
466            let alert = AlertDialog::new(cx)
467                .on_ok(move |_, _, _| {
468                    counter.set(counter.get() + 1);
469                    true
470                })
471                .button_props(DialogButtonProps::default().ok_text("Delete"));
472
473            assert!(alert.base.button_props.ok_handler()(
474                &ClickEvent::default(),
475                window,
476                cx
477            ));
478        });
479
480        assert_eq!(confirmed.get(), 1);
481    }
482
483    /// The direct builders resolve to the same buttons as a props value.
484    #[gpui::test]
485    fn the_direct_builders_match_button_props(cx: &mut TestAppContext) {
486        let cx = window(cx, size(px(800.), px(600.)));
487        cx.update(|_, cx| {
488            let direct = AlertDialog::new(cx)
489                .confirm()
490                .ok_text("Delete")
491                .ok_variant(ButtonVariant::Danger)
492                .cancel_text("Keep")
493                .cancel_variant(ButtonVariant::Ghost);
494            let bundled = AlertDialog::new(cx).button_props(
495                DialogButtonProps::default()
496                    .show_cancel(true)
497                    .ok_text("Delete")
498                    .ok_variant(ButtonVariant::Danger)
499                    .cancel_text("Keep")
500                    .cancel_variant(ButtonVariant::Ghost),
501            );
502
503            for props in [&direct.base.button_props, &bundled.base.button_props] {
504                assert!(props.is_cancel_shown());
505                assert_eq!(props.ok_text.as_deref(), Some("Delete"));
506                assert_eq!(props.ok_variant, Some(ButtonVariant::Danger));
507                assert_eq!(props.cancel_text.as_deref(), Some("Keep"));
508                assert_eq!(props.cancel_variant, Some(ButtonVariant::Ghost));
509            }
510        });
511    }
512
513    /// `margin_top` overrides the tenth-of-the-viewport default offset, which
514    /// seats a short alert near the top of a tall window.
515    #[gpui::test]
516    fn margin_top_moves_the_alert_surface_off_the_default_offset(cx: &mut TestAppContext) {
517        let cx = window(cx, size(px(800.), px(600.)));
518        cx.update(|window, cx| {
519            window.open_alert_dialog(cx, |alert, _, _| {
520                alert
521                    .title("Unsaved changes")
522                    .description("Discard the draft?")
523                    .confirm()
524                    .margin_top(px(100.))
525            })
526        });
527        cx.run_until_parked();
528        // One frame mounts the layer, the next paints it at rest.
529        cx.update(|window, cx| window.draw(cx).clear(cx));
530        cx.update(|window, cx| window.draw(cx).clear(cx));
531
532        let bounds = cx
533            .debug_bounds("dialog-0")
534            .expect("the alert dialog surface was not painted");
535        // The alert chrome carries fractional text metrics (this surface is
536        // 128.5px tall), so the painted origin lands within a subpixel of the
537        // requested offset. The default offset would be a tenth of the
538        // viewport: 60px.
539        assert!(
540            (bounds.origin.y - px(100.)).abs() <= px(0.5),
541            "expected the surface within 0.5px of y=100, got {bounds:?}"
542        );
543    }
544
545    /// `centered` seats the surface in the vertical middle of the window,
546    /// whatever its height.
547    #[gpui::test]
548    fn centered_seats_the_alert_surface_in_the_middle(cx: &mut TestAppContext) {
549        let cx = window(cx, size(px(800.), px(600.)));
550        cx.update(|window, cx| {
551            window.open_alert_dialog(cx, |alert, _, _| {
552                alert
553                    .title("Unsaved changes")
554                    .description("Discard the draft?")
555                    .confirm()
556                    .centered()
557            })
558        });
559        cx.run_until_parked();
560        cx.update(|window, cx| window.draw(cx).clear(cx));
561        cx.update(|window, cx| window.draw(cx).clear(cx));
562
563        let bounds = cx
564            .debug_bounds("dialog-0")
565            .expect("the alert dialog surface was not painted");
566        assert!(
567            (bounds.center().y - px(300.)).abs() <= px(0.5),
568            "expected the surface centered on y=300, got {bounds:?}"
569        );
570    }
571}