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#[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 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 pub fn confirm(mut self) -> Self {
97 self.base.button_props.show_cancel = Some(true);
98 self
99 }
100
101 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
110 self.trigger = Some(trigger.into_any_element());
111 self
112 }
113
114 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 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 #[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 #[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 #[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 #[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 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 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 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 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 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
235 self.base = self.base.width(width);
236 self
237 }
238
239 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 #[deprecated(note = "AlertDialog backdrop dismissal is disabled by design")]
247 pub fn overlay_closable(self, _: bool) -> Self {
248 self
249 }
250
251 pub fn close_button(mut self, close_button: bool) -> Self {
253 self.base = self.base.close_button(close_button);
254 self
255 }
256
257 pub fn keyboard(mut self, keyboard: bool) -> Self {
259 self.base = self.base.keyboard(keyboard);
260 self
261 }
262
263 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 pub fn centered(mut self) -> Self {
277 self.base = self.base.centered();
278 self
279 }
280
281 pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
285 self.base = self.base.entrance(entrance);
286 self
287 }
288
289 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 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 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 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 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 self.render_trigger(trigger, window, cx)
413 } else {
414 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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}