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    /// Sets how the alert dialog enters, defaulting to [`DialogEntrance::SlideDown`].
275    ///
276    /// Shares the surface, backdrop, and reduced-motion policy of [`Dialog::entrance`].
277    pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
278        self.base = self.base.entrance(entrance);
279        self
280    }
281
282    /// Sets the callback for when the alert dialog is closed.
283    ///
284    /// Called after [`Self::on_action`] or [`Self::on_cancel`] callback.
285    pub fn on_close(
286        mut self,
287        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
288    ) -> Self {
289        self.base = self.base.on_close(on_close);
290        self
291    }
292
293    /// Sets the callback for when the OK/action button is clicked.
294    ///
295    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
296    pub fn on_ok(
297        mut self,
298        on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
299    ) -> Self {
300        self.base = self.base.on_ok(on_ok);
301        self
302    }
303
304    /// Sets the callback for when the alert dialog has been canceled.
305    ///
306    /// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
307    pub fn on_cancel(
308        mut self,
309        on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
310    ) -> Self {
311        self.base = self.base.on_cancel(on_cancel);
312        self
313    }
314
315    /// Build the styled dialog surface around the Base alert-dialog host.
316    pub(crate) fn build_surface(self, window: &mut Window, cx: &mut App) -> Dialog {
317        let button_props = self.base.button_props.clone();
318        let has_title = self.icon.is_some() || self.title.is_some();
319        let has_header = has_title || self.description.is_some();
320        let has_footer = self.base.footer.is_some();
321
322        self.base
323            .when(has_header, |this| {
324                this.header(
325                    DialogHeader::new().child(
326                        h_flex()
327                            .gap_2()
328                            .items_start()
329                            .when_some(self.icon, |row, icon| row.child(icon))
330                            .child(
331                                v_flex()
332                                    .flex_1()
333                                    .min_w_0()
334                                    .gap_1()
335                                    .when_some(self.title, |this, title| {
336                                        this.child(DialogTitle::new().child(title))
337                                    })
338                                    .when_some(self.description, |this, desc| {
339                                        this.child(DialogDescription::new().child(desc))
340                                    }),
341                            ),
342                    ),
343                )
344            })
345            .children(self.children)
346            .when(!has_footer, |this| {
347                // Default footer for AlertDialog if user doesn't provide one, with OK and optional Cancel button
348                this.footer(
349                    DialogFooter::new()
350                        .when(button_props.is_cancel_shown(), |this| {
351                            this.child(button_props.render_cancel(window, cx))
352                        })
353                        .child(button_props.render_ok(window, cx)),
354                )
355            })
356    }
357}
358
359impl Styled for AlertDialog {
360    fn style(&mut self) -> &mut StyleRefinement {
361        &mut self.base.style
362    }
363}
364
365impl ParentElement for AlertDialog {
366    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
367        self.children.extend(elements);
368    }
369}
370
371impl AlertDialog {
372    fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
373        let content_builder = self.base.content_builder.clone();
374        let style = self.base.style.clone();
375        let props = self.base.props.clone();
376        let button_props = self.base.button_props.clone();
377
378        gpui_base::AlertDialogTrigger::new(trigger)
379            .on_open(move |window, cx| {
380                let content_builder = content_builder.clone();
381                let style = style.clone();
382                let props = props.clone();
383                let button_props = button_props.clone();
384                window.open_dialog(cx, move |dialog, _, cx| {
385                    dialog
386                        .with_base_alert_dialog(gpui_base::AlertDialog::new(cx))
387                        .refine_style(&style)
388                        .button_props(button_props.clone())
389                        .with_props(props.clone())
390                        .when_some(content_builder.clone(), |this, content_builder| {
391                            this.content(move |content, window, cx| {
392                                content_builder(content, window, cx)
393                            })
394                        })
395                });
396            })
397            .into_any_element()
398    }
399}
400
401impl RenderOnce for AlertDialog {
402    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
403        if let Some(trigger) = self.trigger.take() {
404            // If a trigger is provided, render the trigger element that opens the dialog
405            self.render_trigger(trigger, window, cx)
406        } else {
407            // Otherwise, render the dialog content directly
408            self.build_surface(window, cx).into_any_element()
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use std::{cell::Cell, rc::Rc};
416
417    use gpui::{ClickEvent, TestAppContext, px, size};
418
419    use super::*;
420    use crate::dialog::dialog::tests::window;
421
422    /// `button_props` overrides only the fields it sets, so the Cancel button
423    /// `confirm` asked for survives a later props value that never mentions it.
424    #[gpui::test]
425    fn button_props_after_confirm_keeps_the_cancel_button(cx: &mut TestAppContext) {
426        let cx = window(cx, size(px(800.), px(600.)));
427        cx.update(|_, cx| {
428            let alert = AlertDialog::new(cx)
429                .confirm()
430                .button_props(DialogButtonProps::default().ok_text("Delete"));
431
432            assert!(alert.base.button_props.is_cancel_shown());
433            assert_eq!(alert.base.button_props.ok_text.as_deref(), Some("Delete"));
434        });
435    }
436
437    /// The call order does not matter either way round.
438    #[gpui::test]
439    fn confirm_after_button_props_keeps_the_ok_text(cx: &mut TestAppContext) {
440        let cx = window(cx, size(px(800.), px(600.)));
441        cx.update(|_, cx| {
442            let alert = AlertDialog::new(cx)
443                .button_props(DialogButtonProps::default().ok_text("Delete"))
444                .confirm();
445
446            assert!(alert.base.button_props.is_cancel_shown());
447            assert_eq!(alert.base.button_props.ok_text.as_deref(), Some("Delete"));
448        });
449    }
450
451    /// A callback installed with `on_ok` survives a later props value that
452    /// carries no callback of its own.
453    #[gpui::test]
454    fn button_props_after_on_ok_keeps_the_callback(cx: &mut TestAppContext) {
455        let cx = window(cx, size(px(800.), px(600.)));
456        let confirmed = Rc::new(Cell::new(0));
457        let counter = confirmed.clone();
458        cx.update(|window, cx| {
459            let alert = AlertDialog::new(cx)
460                .on_ok(move |_, _, _| {
461                    counter.set(counter.get() + 1);
462                    true
463                })
464                .button_props(DialogButtonProps::default().ok_text("Delete"));
465
466            assert!(alert.base.button_props.ok_handler()(
467                &ClickEvent::default(),
468                window,
469                cx
470            ));
471        });
472
473        assert_eq!(confirmed.get(), 1);
474    }
475
476    /// The direct builders resolve to the same buttons as a props value.
477    #[gpui::test]
478    fn the_direct_builders_match_button_props(cx: &mut TestAppContext) {
479        let cx = window(cx, size(px(800.), px(600.)));
480        cx.update(|_, cx| {
481            let direct = AlertDialog::new(cx)
482                .confirm()
483                .ok_text("Delete")
484                .ok_variant(ButtonVariant::Danger)
485                .cancel_text("Keep")
486                .cancel_variant(ButtonVariant::Ghost);
487            let bundled = AlertDialog::new(cx).button_props(
488                DialogButtonProps::default()
489                    .show_cancel(true)
490                    .ok_text("Delete")
491                    .ok_variant(ButtonVariant::Danger)
492                    .cancel_text("Keep")
493                    .cancel_variant(ButtonVariant::Ghost),
494            );
495
496            for props in [&direct.base.button_props, &bundled.base.button_props] {
497                assert!(props.is_cancel_shown());
498                assert_eq!(props.ok_text.as_deref(), Some("Delete"));
499                assert_eq!(props.ok_variant, Some(ButtonVariant::Danger));
500                assert_eq!(props.cancel_text.as_deref(), Some("Keep"));
501                assert_eq!(props.cancel_variant, Some(ButtonVariant::Ghost));
502            }
503        });
504    }
505
506    /// `margin_top` overrides the tenth-of-the-viewport default offset, which
507    /// seats a short alert near the top of a tall window.
508    #[gpui::test]
509    fn margin_top_moves_the_alert_surface_off_the_default_offset(cx: &mut TestAppContext) {
510        let cx = window(cx, size(px(800.), px(600.)));
511        cx.update(|window, cx| {
512            window.open_alert_dialog(cx, |alert, _, _| {
513                alert
514                    .title("Unsaved changes")
515                    .description("Discard the draft?")
516                    .confirm()
517                    .margin_top(px(100.))
518            })
519        });
520        cx.run_until_parked();
521        // One frame mounts the layer, the next paints it at rest.
522        cx.update(|window, cx| window.draw(cx).clear(cx));
523        cx.update(|window, cx| window.draw(cx).clear(cx));
524
525        let bounds = cx
526            .debug_bounds("dialog-0")
527            .expect("the alert dialog surface was not painted");
528        // The alert chrome carries fractional text metrics (this surface is
529        // 128.5px tall), so the painted origin lands within a subpixel of the
530        // requested offset. The default offset would be a tenth of the
531        // viewport: 60px.
532        assert!(
533            (bounds.origin.y - px(100.)).abs() <= px(0.5),
534            "expected the surface within 0.5px of y=100, got {bounds:?}"
535        );
536    }
537}