1use gpui_base::TestSupportExt as _;
2use std::{rc::Rc, sync::LazyLock, time::Duration};
3
4use gpui::{
5 Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Edges, FocusHandle, Hsla,
6 InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
7 StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point,
8 prelude::FluentBuilder, px,
9};
10use gpui_base::{ElementExt as _, TextSelectionScopeId};
11use rust_i18n::t;
12
13use crate::{
14 ActiveTheme as _, IconName, Root, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _,
15 animation::cubic_bezier,
16 button::{Button, ButtonVariant, ButtonVariants as _},
17 dialog::{DialogContent, DialogTitle},
18 scroll::ScrollableElement as _,
19 v_flex,
20};
21
22pub static ANIMATION_DURATION: LazyLock<Duration> = LazyLock::new(|| Duration::from_secs_f64(0.25));
23pub use gpui_base::actions::{Cancel, Confirm};
24
25#[derive(Clone)]
27pub struct DialogButtonProps {
28 pub(crate) ok_text: Option<SharedString>,
29 pub(crate) ok_variant: ButtonVariant,
30 pub(crate) cancel_text: Option<SharedString>,
31 pub(crate) cancel_variant: ButtonVariant,
32 pub(crate) show_cancel: bool,
33 pub(crate) on_ok: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>,
34 pub(crate) on_cancel: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>,
35 pub(crate) on_close: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
36}
37
38impl Default for DialogButtonProps {
39 fn default() -> Self {
40 Self {
41 ok_text: None,
42 ok_variant: ButtonVariant::Primary,
43 cancel_text: None,
44 cancel_variant: ButtonVariant::default(),
45 show_cancel: false,
46 on_ok: Rc::new(|_, _, _| true),
47 on_cancel: Rc::new(|_, _, _| true),
48 on_close: Rc::new(|_, _, _| {}),
49 }
50 }
51}
52
53impl DialogButtonProps {
54 pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
56 self.ok_text = Some(ok_text.into());
57 self
58 }
59
60 pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
62 self.ok_variant = ok_variant;
63 self
64 }
65
66 pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
68 self.cancel_text = Some(cancel_text.into());
69 self
70 }
71
72 pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
74 self.cancel_variant = cancel_variant;
75 self
76 }
77
78 pub fn show_cancel(mut self, show_cancel: bool) -> Self {
80 self.show_cancel = show_cancel;
81 self
82 }
83
84 pub fn on_ok(
88 mut self,
89 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
90 ) -> Self {
91 self.on_ok = Rc::new(on_ok);
92 self
93 }
94
95 pub fn on_cancel(
99 mut self,
100 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
101 ) -> Self {
102 self.on_cancel = Rc::new(on_cancel);
103 self
104 }
105
106 pub(crate) fn render_ok(&self, _: &mut Window, _: &mut App) -> AnyElement {
107 let ok_text = self
108 .ok_text
109 .clone()
110 .unwrap_or_else(|| t!("Dialog.ok").into());
111 let ok_variant = self.ok_variant;
112
113 Button::new("ok")
114 .label(ok_text)
115 .with_variant(ok_variant)
116 .on_click(|_, window, cx| {
117 window.dispatch_action(Box::new(Confirm { secondary: false }), cx)
118 })
119 .into_any_element()
120 }
121
122 pub(crate) fn render_cancel(&self, _: &mut Window, _: &mut App) -> AnyElement {
123 let cancel_text = self
124 .cancel_text
125 .clone()
126 .unwrap_or_else(|| t!("Dialog.cancel").into());
127 let cancel_variant = self.cancel_variant;
128
129 Button::new("cancel")
130 .label(cancel_text)
131 .with_variant(cancel_variant)
132 .on_click(|_, window, cx| window.dispatch_action(Box::new(Cancel), cx))
133 .into_any_element()
134 }
135}
136
137type ContentBuilderFn = Rc<dyn Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static>;
138
139#[derive(Clone)]
140pub(crate) struct DialogProps {
141 width: Pixels,
142 max_width: Option<Pixels>,
143 margin_top: Option<Pixels>,
144 close_button: bool,
145
146 overlay: bool,
147 overlay_closable: bool,
148 pub(crate) overlay_visible: bool,
149 keyboard: bool,
150}
151
152impl Default for DialogProps {
153 fn default() -> Self {
154 Self {
155 margin_top: None,
156 width: px(448.),
157 max_width: None,
158 overlay: true,
159 keyboard: true,
160 overlay_visible: false,
161 close_button: true,
162 overlay_closable: true,
163 }
164 }
165}
166
167enum BaseDialogRoot {
168 Dialog(gpui_base::Dialog),
169 AlertDialog(gpui_base::AlertDialog),
170}
171
172macro_rules! map_base_root {
173 ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
174 match $self {
175 BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
176 BaseDialogRoot::AlertDialog(root) => {
177 BaseDialogRoot::AlertDialog(root.$method($($arg),*))
178 }
179 }
180 };
181}
182
183impl BaseDialogRoot {
184 fn layer(self, index: usize, topmost: bool) -> Self {
185 map_base_root!(self, layer(index, topmost))
186 }
187 fn focus_handle(self, focus: FocusHandle) -> Self {
188 map_base_root!(self, focus_handle(focus))
189 }
190 fn close_on_escape(self, value: bool) -> Self {
191 map_base_root!(self, close_on_escape(value))
192 }
193 fn close_on_backdrop_press(self, value: bool) -> Self {
194 match self {
195 Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
196 Self::AlertDialog(root) => Self::AlertDialog(root),
197 }
198 }
199 fn dismiss_below_y(self, value: Pixels) -> Self {
200 map_base_root!(self, dismiss_below_y(value))
201 }
202 fn backdrop(self, element: impl IntoElement) -> Self {
203 map_base_root!(self, backdrop(element))
204 }
205 fn popup(self, element: impl IntoElement) -> Self {
206 map_base_root!(self, popup(element))
207 }
208 fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
209 map_base_root!(self, on_ok(handler))
210 }
211 fn on_cancel(
212 self,
213 handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
214 ) -> Self {
215 map_base_root!(self, on_cancel(handler))
216 }
217 fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
218 map_base_root!(self, on_close(handler))
219 }
220 fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
221 map_base_root!(self, request_close(handler))
222 }
223}
224
225impl IntoElement for BaseDialogRoot {
226 type Element = <gpui_base::Dialog as IntoElement>::Element;
227 fn into_element(self) -> Self::Element {
228 match self {
229 Self::Dialog(root) => root.into_element(),
230 Self::AlertDialog(root) => root.into_element(),
231 }
232 }
233}
234
235#[derive(IntoElement)]
237pub struct Dialog {
238 base: Option<BaseDialogRoot>,
239 pub(crate) style: StyleRefinement,
240 children: Vec<AnyElement>,
241 trigger: Option<AnyElement>,
242 title: Option<AnyElement>,
243 pub(crate) header: Option<AnyElement>,
244 pub(crate) footer: Option<AnyElement>,
245 pub(crate) content_builder: Option<ContentBuilderFn>,
246 pub(crate) props: DialogProps,
247
248 pub(super) button_props: DialogButtonProps,
249
250 pub(crate) focus_handle: FocusHandle,
252 pub(crate) layer_ix: usize,
253 pub(crate) selection_scope: TextSelectionScopeId,
254}
255
256pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
257 if !overlay {
258 return hsla(0., 0., 0., 0.);
259 }
260
261 cx.theme().overlay
262}
263
264impl Dialog {
265 pub fn new(cx: &mut App) -> Self {
267 Self {
268 base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
269 focus_handle: cx.focus_handle(),
270 style: StyleRefinement::default(),
271 trigger: None,
272 title: None,
273 header: None,
274 footer: None,
275 content_builder: None,
276 props: DialogProps::default(),
277 children: Vec::new(),
278 layer_ix: 0,
279 selection_scope: TextSelectionScopeId::default(),
280 button_props: DialogButtonProps::default(),
281 }
282 }
283
284 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
288 self.trigger = Some(trigger.into_any_element());
289 self
290 }
291
292 pub fn content<F>(mut self, builder: F) -> Self
294 where
295 F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
296 {
297 self.content_builder = Some(Rc::new(builder));
298 self
299 }
300
301 pub fn title(mut self, title: impl IntoElement) -> Self {
303 self.title = Some(title.into_any_element());
304 self
305 }
306
307 pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
311 self.header = Some(header.into_any_element());
312 self
313 }
314
315 pub fn footer(mut self, footer: impl IntoElement) -> Self {
319 self.footer = Some(footer.into_any_element());
320 self
321 }
322
323 pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
325 self.button_props = button_props;
326 self
327 }
328 pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
329 self.base = Some(BaseDialogRoot::AlertDialog(base));
330 self.props.overlay_closable = false;
331 self
332 }
333
334 pub fn on_close(
338 mut self,
339 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
340 ) -> Self {
341 self.button_props.on_close = Rc::new(on_close);
342 self
343 }
344
345 pub fn on_ok(
349 mut self,
350 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
351 ) -> Self {
352 self.button_props = self.button_props.on_ok(on_ok);
353 self
354 }
355
356 pub fn on_cancel(
360 mut self,
361 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
362 ) -> Self {
363 self.button_props = self.button_props.on_cancel(on_cancel);
364 self
365 }
366
367 pub fn close_button(mut self, close_button: bool) -> Self {
369 self.props.close_button = close_button;
370 self
371 }
372
373 pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
375 self.props.margin_top = Some(margin_top.into());
376 self
377 }
378
379 pub fn w(mut self, width: impl Into<Pixels>) -> Self {
383 self.props.width = width.into();
384 self
385 }
386
387 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
389 self.props.width = width.into();
390 self
391 }
392
393 pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
395 self.props.max_width = Some(max_width.into());
396 self
397 }
398
399 pub fn overlay(mut self, overlay: bool) -> Self {
401 self.props.overlay = overlay;
402 self
403 }
404
405 pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
409 self.props.overlay_closable = overlay_closable;
410 self
411 }
412
413 pub fn keyboard(mut self, keyboard: bool) -> Self {
415 self.props.keyboard = keyboard;
416 self
417 }
418
419 pub(crate) fn has_overlay(&self) -> bool {
420 self.props.overlay
421 }
422
423 pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
424 self.props = props;
425 self
426 }
427
428 fn defer_close_dialog(window: &mut Window, cx: &mut App) {
429 Root::update(window, cx, |root, window, cx| {
430 root.defer_close_dialog(window, cx);
431 });
432 }
433}
434
435impl ParentElement for Dialog {
436 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
437 self.children.extend(elements);
438 }
439}
440
441impl Styled for Dialog {
442 fn style(&mut self) -> &mut gpui::StyleRefinement {
443 &mut self.style
444 }
445}
446
447impl Dialog {
448 fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
449 let content_builder = self.content_builder.clone();
450 let style = self.style.clone();
451 let props = self.props.clone();
452 let button_props = self.button_props.clone();
453
454 gpui_base::DialogTrigger::new(trigger)
455 .on_open(move |window, cx| {
456 let content_builder = content_builder.clone();
457 let style = style.clone();
458 let props = props.clone();
459 let button_props = button_props.clone();
460 window.open_dialog(cx, move |dialog, _, _| {
461 dialog
462 .refine_style(&style)
463 .button_props(button_props.clone())
464 .with_props(props.clone())
465 .content({
466 let content_builder = content_builder.clone();
467 move |content, window, cx| {
468 if let Some(builder) = content_builder.clone() {
469 builder(content, window, cx)
470 } else {
471 content
472 }
473 }
474 })
475 });
476 })
477 .into_any_element()
478 }
479}
480
481impl RenderOnce for Dialog {
482 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
483 if let Some(trigger) = self.trigger.take() {
484 return self.render_trigger(trigger, window, cx);
485 }
486
487 let layer_ix = self.layer_ix;
488 let selection_scope = self.selection_scope;
489 let on_close = self.button_props.on_close.clone();
490 let on_ok = self.button_props.on_ok.clone();
491 let on_cancel = self.button_props.on_cancel.clone();
492
493 let window_paddings = crate::window_border::window_paddings(window);
494 let view_size = window.viewport_size()
495 - gpui::size(
496 window_paddings.left + window_paddings.right,
497 window_paddings.top + window_paddings.bottom,
498 );
499 let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + px(layer_ix as f32 * 16.);
500 let x = view_size.width / 2. - self.props.width / 2.;
501
502 let base_size = window.text_style().font_size;
503 let rem_size = window.rem_size();
504
505 let mut paddings = Edges::all(px(16.));
506 if let Some(pl) = self.style.padding.left {
507 paddings.left = pl.to_pixels(base_size, rem_size);
508 }
509 if let Some(pr) = self.style.padding.right {
510 paddings.right = pr.to_pixels(base_size, rem_size);
511 }
512 if let Some(pt) = self.style.padding.top {
513 paddings.top = pt.to_pixels(base_size, rem_size);
514 }
515 if let Some(pb) = self.style.padding.bottom {
516 paddings.bottom = pb.to_pixels(base_size, rem_size);
517 }
518
519 let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
524 1. / 3.,
525 0.72,
526 2. / 3.,
527 1.,
528 ));
529
530 anchored()
531 .position(point(window_paddings.left, window_paddings.top))
532 .snap_to_window()
533 .child(
534 div()
535 .id("dialog")
536 .test_support()
537 .occlude()
538 .w(view_size.width)
539 .h(view_size.height)
540 .child(
541 self.base
542 .take()
543 .expect("Dialog base host is always present")
544 .layer(
545 layer_ix,
546 (self.layer_ix + 1) == Root::read(window, cx).active_dialogs.len(),
547 )
548 .focus_handle(self.focus_handle.clone())
549 .close_on_escape(self.props.keyboard)
550 .close_on_backdrop_press(self.props.overlay_closable)
551 .dismiss_below_y(TITLE_BAR_HEIGHT)
552 .when(self.props.overlay, |this| {
553 this.backdrop(
554 div()
555 .absolute()
556 .size_full()
557 .window_control_area(WindowControlArea::Drag)
558 .when(self.props.overlay_visible, |overlay| {
559 overlay.bg(overlay_color(true, cx))
560 }),
561 )
562 })
563 .on_ok(move |event, window, cx| on_ok(event, window, cx))
564 .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
565 .on_close(move |event, window, cx| on_close(event, window, cx))
566 .request_close(move |deferred, window, cx| {
567 if deferred {
568 Self::defer_close_dialog(window, cx);
569 } else {
570 window.close_dialog(cx);
571 }
572 })
573 .popup(
574 v_flex()
575 .id(layer_ix)
576 .test_support()
577 .bg(cx.theme().tokens.background)
578 .border_1()
579 .border_color(cx.theme().border)
580 .rounded(cx.theme().radius_lg)
581 .min_h_24()
582 .pt(paddings.top)
583 .pb(paddings.bottom)
584 .gap(paddings.top.max(px(8.)))
585 .refine_style(&self.style)
586 .px_0()
587 .absolute()
589 .occlude()
590 .relative()
591 .left(x)
592 .top(y)
593 .w(self.props.width)
594 .when_some(self.props.max_width, |this, w| this.max_w(w))
595 .child(
596 v_flex()
597 .flex_1()
598 .overflow_hidden()
599 .gap_y_2()
600 .when_some(self.header, |this, header| {
601 this.child(
602 div()
603 .pl(paddings.left)
604 .pr(paddings.right)
605 .child(header),
606 )
607 })
608 .when_some(self.title, |this, title| {
609 this.child(
610 DialogTitle::new()
611 .pl(paddings.left)
612 .pr(paddings.right)
613 .child(title),
614 )
615 })
616 .when_some(self.content_builder, |this, builder| {
617 this.child(builder(
618 DialogContent::new()
619 .gap(paddings.bottom)
620 .pl(paddings.left)
621 .pr(paddings.right),
622 window,
623 cx,
624 ))
625 })
626 .when(!self.children.is_empty(), |this| {
627 this.child(
628 div().flex_1().overflow_hidden().child(
629 v_flex()
631 .size_full()
632 .overflow_y_scrollbar()
633 .pl(paddings.left)
634 .pr(paddings.right)
635 .children(self.children),
636 ),
637 )
638 }),
639 )
640 .when_some(self.footer, |this, footer| {
641 this.child(
642 div()
643 .pl(paddings.left)
644 .pr(paddings.right)
645 .child(footer),
646 )
647 })
648 .children(self.props.close_button.then(|| {
649 let top = (paddings.top - px(10.)).max(px(8.));
650 let right = (paddings.right - px(10.)).max(px(8.));
651
652 gpui_base::DialogClose::new()
653 .absolute()
654 .top(top)
655 .right(right)
656 .trigger(|button| {
657 Button::new("close")
658 .with_base(button)
659 .small()
660 .ghost()
661 .icon(IconName::Close)
662 })
663 }))
664 .with_animation(
665 "slide-down",
666 animation.clone(),
667 move |this, delta| {
668 let shadow = vec![
670 BoxShadow {
671 color: hsla(0., 0., 0., 0.1 * delta),
672 offset: point(px(0.), px(20.)),
673 blur_radius: px(25.),
674 spread_radius: px(-5.),
675 inset: false,
676 },
677 BoxShadow {
678 color: hsla(0., 0., 0., 0.1 * delta),
679 offset: point(px(0.), px(8.)),
680 blur_radius: px(10.),
681 spread_radius: px(-6.),
682 inset: false,
683 },
684 ];
685 this.top(y * delta).shadow(shadow)
686 },
687 )
688 .text_selection_scope(selection_scope),
689 ),
690 )
691 .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
692 )
693 .into_any_element()
694 }
695}