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}