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