1use super::entrance::{DialogEntrance, EntranceSurface};
2use crate::root::WindowState;
3use gpui_base::TestSupportExt as _;
4use std::{rc::Rc, sync::LazyLock, time::Duration};
5
6use gpui::{
7 Action, Anchor, Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Edges,
8 FocusHandle, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
9 SharedString, StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point,
10 prelude::FluentBuilder, px,
11};
12use gpui_base::{ElementExt as _, TextSelectionScopeId};
13use rust_i18n::t;
14
15use crate::{
16 ActiveTheme as _, IconName, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _,
17 animation::cubic_bezier,
18 button::{Button, ButtonVariant, ButtonVariants as _},
19 dialog::{DialogContent, DialogDispatchAnchor, DialogTitle},
20 scroll::ScrollableElement as _,
21 v_flex,
22};
23
24pub static ANIMATION_DURATION: LazyLock<Duration> = LazyLock::new(|| Duration::from_secs_f64(0.25));
25pub use gpui_base::actions::{Cancel, Confirm};
26
27type OkHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
28type CancelHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
29type CloseHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
30
31fn merge_field<T>(slot: &mut Option<T>, value: Option<T>) {
33 if value.is_some() {
34 *slot = value;
35 }
36}
37
38#[derive(Clone, Default)]
47pub struct DialogButtonProps {
48 pub(crate) ok_text: Option<SharedString>,
49 pub(crate) ok_variant: Option<ButtonVariant>,
50 pub(crate) cancel_text: Option<SharedString>,
51 pub(crate) cancel_variant: Option<ButtonVariant>,
52 pub(crate) show_cancel: Option<bool>,
53 pub(crate) on_ok: Option<OkHandler>,
54 pub(crate) on_cancel: Option<CancelHandler>,
55 pub(crate) on_close: Option<CloseHandler>,
56}
57
58impl DialogButtonProps {
59 pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
61 self.ok_text = Some(ok_text.into());
62 self
63 }
64
65 pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
67 self.ok_variant = Some(ok_variant);
68 self
69 }
70
71 pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
73 self.cancel_text = Some(cancel_text.into());
74 self
75 }
76
77 pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
79 self.cancel_variant = Some(cancel_variant);
80 self
81 }
82
83 pub fn show_cancel(mut self, show_cancel: bool) -> Self {
85 self.show_cancel = Some(show_cancel);
86 self
87 }
88
89 pub fn on_ok(
93 mut self,
94 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
95 ) -> Self {
96 self.on_ok = Some(Rc::new(on_ok));
97 self
98 }
99
100 pub fn on_cancel(
104 mut self,
105 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
106 ) -> Self {
107 self.on_cancel = Some(Rc::new(on_cancel));
108 self
109 }
110
111 pub(crate) fn merge(&mut self, other: Self) {
113 merge_field(&mut self.ok_text, other.ok_text);
114 merge_field(&mut self.ok_variant, other.ok_variant);
115 merge_field(&mut self.cancel_text, other.cancel_text);
116 merge_field(&mut self.cancel_variant, other.cancel_variant);
117 merge_field(&mut self.show_cancel, other.show_cancel);
118 merge_field(&mut self.on_ok, other.on_ok);
119 merge_field(&mut self.on_cancel, other.on_cancel);
120 merge_field(&mut self.on_close, other.on_close);
121 }
122
123 pub(crate) fn is_cancel_shown(&self) -> bool {
125 self.show_cancel.unwrap_or(false)
126 }
127
128 pub(crate) fn ok_handler(&self) -> OkHandler {
130 self.on_ok
131 .clone()
132 .unwrap_or_else(|| Rc::new(|_, _, _| true))
133 }
134
135 pub(crate) fn cancel_handler(&self) -> CancelHandler {
137 self.on_cancel
138 .clone()
139 .unwrap_or_else(|| Rc::new(|_, _, _| true))
140 }
141
142 pub(crate) fn close_handler(&self) -> CloseHandler {
144 self.on_close
145 .clone()
146 .unwrap_or_else(|| Rc::new(|_, _, _| {}))
147 }
148
149 pub(crate) fn render_ok(&self, _: &mut Window, _: &mut App) -> AnyElement {
150 let ok_text = self
151 .ok_text
152 .clone()
153 .unwrap_or_else(|| t!("Dialog.ok").into());
154
155 DialogButton {
156 anchor_key: "dialog-ok-anchor",
157 button: Button::new("ok")
158 .label(ok_text)
159 .with_variant(self.ok_variant.unwrap_or(ButtonVariant::Primary)),
160 action: Rc::new(Confirm { secondary: false }),
161 }
162 .into_any_element()
163 }
164
165 pub(crate) fn render_cancel(&self, _: &mut Window, _: &mut App) -> AnyElement {
166 let cancel_text = self
167 .cancel_text
168 .clone()
169 .unwrap_or_else(|| t!("Dialog.cancel").into());
170
171 DialogButton {
172 anchor_key: "dialog-cancel-anchor",
173 button: Button::new("cancel")
174 .label(cancel_text)
175 .with_variant(self.cancel_variant.unwrap_or_default()),
176 action: Rc::new(Cancel),
177 }
178 .into_any_element()
179 }
180}
181
182#[derive(IntoElement)]
185struct DialogButton {
186 anchor_key: &'static str,
188 button: Button,
189 action: Rc<dyn Action>,
190}
191
192impl RenderOnce for DialogButton {
193 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
194 let anchor = DialogDispatchAnchor::new(self.anchor_key, window, cx);
195 self.button
196 .child(anchor.element())
197 .on_click(move |_, window, cx| anchor.dispatch(&*self.action, window, cx))
198 }
199}
200
201type ContentBuilderFn = Rc<dyn Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static>;
202
203#[derive(Clone)]
204pub(crate) struct DialogProps {
205 width: Pixels,
206 max_width: Option<Pixels>,
207 margin_top: Option<Pixels>,
208 close_button: bool,
209
210 overlay: bool,
211 overlay_closable: bool,
212 pub(crate) overlay_visible: bool,
213 keyboard: bool,
214 entrance: DialogEntrance,
215}
216
217impl Default for DialogProps {
218 fn default() -> Self {
219 Self {
220 margin_top: None,
221 width: px(448.),
222 max_width: None,
223 overlay: true,
224 keyboard: true,
225 overlay_visible: false,
226 close_button: true,
227 overlay_closable: true,
228 entrance: DialogEntrance::default(),
229 }
230 }
231}
232
233enum BaseDialogRoot {
234 Dialog(gpui_base::Dialog),
235 AlertDialog(gpui_base::AlertDialog),
236}
237
238macro_rules! map_base_root {
239 ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
240 match $self {
241 BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
242 BaseDialogRoot::AlertDialog(root) => {
243 BaseDialogRoot::AlertDialog(root.$method($($arg),*))
244 }
245 }
246 };
247}
248
249impl BaseDialogRoot {
250 fn layer(self, index: usize, topmost: bool) -> Self {
251 map_base_root!(self, layer(index, topmost))
252 }
253 fn focus_handle(self, focus: FocusHandle) -> Self {
254 map_base_root!(self, focus_handle(focus))
255 }
256 fn close_on_escape(self, value: bool) -> Self {
257 map_base_root!(self, close_on_escape(value))
258 }
259 fn close_on_backdrop_press(self, value: bool) -> Self {
260 match self {
261 Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
262 Self::AlertDialog(root) => Self::AlertDialog(root),
263 }
264 }
265 fn dismiss_below_y(self, value: Pixels) -> Self {
266 map_base_root!(self, dismiss_below_y(value))
267 }
268 fn backdrop(self, element: impl IntoElement) -> Self {
269 map_base_root!(self, backdrop(element))
270 }
271 fn popup(self, element: impl IntoElement) -> Self {
272 map_base_root!(self, popup(element))
273 }
274 fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
275 map_base_root!(self, on_ok(handler))
276 }
277 fn on_cancel(
278 self,
279 handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
280 ) -> Self {
281 map_base_root!(self, on_cancel(handler))
282 }
283 fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
284 map_base_root!(self, on_close(handler))
285 }
286 fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
287 map_base_root!(self, request_close(handler))
288 }
289}
290
291impl IntoElement for BaseDialogRoot {
292 type Element = <gpui_base::Dialog as IntoElement>::Element;
293 fn into_element(self) -> Self::Element {
294 match self {
295 Self::Dialog(root) => root.into_element(),
296 Self::AlertDialog(root) => root.into_element(),
297 }
298 }
299}
300
301#[derive(IntoElement)]
303pub struct Dialog {
304 base: Option<BaseDialogRoot>,
305 pub(crate) style: StyleRefinement,
306 children: Vec<AnyElement>,
307 trigger: Option<AnyElement>,
308 title: Option<AnyElement>,
309 pub(crate) header: Option<AnyElement>,
310 pub(crate) footer: Option<AnyElement>,
311 pub(crate) content_builder: Option<ContentBuilderFn>,
312 pub(crate) props: DialogProps,
313
314 pub(super) button_props: DialogButtonProps,
315
316 pub(crate) focus_handle: FocusHandle,
318 pub(crate) layer_ix: usize,
319 pub(crate) selection_scope: TextSelectionScopeId,
320}
321
322pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
323 if !overlay {
324 return hsla(0., 0., 0., 0.);
325 }
326
327 cx.theme().overlay
328}
329
330fn dialog_shadow(delta: f32) -> Vec<BoxShadow> {
331 vec![
332 BoxShadow {
333 color: hsla(0., 0., 0., 0.1 * delta),
334 offset: point(px(0.), px(20.)),
335 blur_radius: px(25.),
336 spread_radius: px(-5.),
337 inset: false,
338 },
339 BoxShadow {
340 color: hsla(0., 0., 0., 0.1 * delta),
341 offset: point(px(0.), px(8.)),
342 blur_radius: px(10.),
343 spread_radius: px(-6.),
344 inset: false,
345 },
346 ]
347}
348
349impl Dialog {
350 pub fn new(cx: &mut App) -> Self {
352 Self {
353 base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
354 focus_handle: cx.focus_handle(),
355 style: StyleRefinement::default(),
356 trigger: None,
357 title: None,
358 header: None,
359 footer: None,
360 content_builder: None,
361 props: DialogProps::default(),
362 children: Vec::new(),
363 layer_ix: 0,
364 selection_scope: TextSelectionScopeId::default(),
365 button_props: DialogButtonProps::default(),
366 }
367 }
368
369 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
373 self.trigger = Some(trigger.into_any_element());
374 self
375 }
376
377 pub fn content<F>(mut self, builder: F) -> Self
379 where
380 F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
381 {
382 self.content_builder = Some(Rc::new(builder));
383 self
384 }
385
386 pub fn title(mut self, title: impl IntoElement) -> Self {
388 self.title = Some(title.into_any_element());
389 self
390 }
391
392 pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
394 self.header = Some(header.into_any_element());
395 self
396 }
397
398 pub fn footer(mut self, footer: impl IntoElement) -> Self {
407 self.footer = Some(footer.into_any_element());
408 self
409 }
410
411 pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
423 self.button_props.merge(button_props);
424 self
425 }
426
427 pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
428 self.base = Some(BaseDialogRoot::AlertDialog(base));
429 self.props.overlay_closable = false;
430 self
431 }
432
433 pub fn on_close(
437 mut self,
438 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
439 ) -> Self {
440 self.button_props.on_close = Some(Rc::new(on_close));
441 self
442 }
443
444 pub fn on_ok(
448 mut self,
449 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
450 ) -> Self {
451 self.button_props = self.button_props.on_ok(on_ok);
452 self
453 }
454
455 pub fn on_cancel(
459 mut self,
460 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
461 ) -> Self {
462 self.button_props = self.button_props.on_cancel(on_cancel);
463 self
464 }
465
466 pub fn close_button(mut self, close_button: bool) -> Self {
468 self.props.close_button = close_button;
469 self
470 }
471
472 pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
474 self.props.margin_top = Some(margin_top.into());
475 self
476 }
477
478 pub fn w(mut self, width: impl Into<Pixels>) -> Self {
484 self.props.width = width.into();
485 self
486 }
487
488 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
492 self.props.width = width.into();
493 self
494 }
495
496 pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
498 self.props.max_width = Some(max_width.into());
499 self
500 }
501
502 pub fn overlay(mut self, overlay: bool) -> Self {
504 self.props.overlay = overlay;
505 self
506 }
507
508 pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
512 self.props.overlay_closable = overlay_closable;
513 self
514 }
515
516 pub fn keyboard(mut self, keyboard: bool) -> Self {
518 self.props.keyboard = keyboard;
519 self
520 }
521
522 pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
527 self.props.entrance = entrance;
528 self
529 }
530
531 pub(crate) fn has_overlay(&self) -> bool {
532 self.props.overlay
533 }
534
535 pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
536 self.props = props;
537 self
538 }
539
540 fn defer_close_dialog(window: &mut Window, cx: &mut App) {
541 WindowState::update(window, cx, |root, window, cx| {
542 root.defer_close_dialog(window, cx);
543 });
544 }
545}
546
547impl ParentElement for Dialog {
548 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
549 self.children.extend(elements);
550 }
551}
552
553impl Styled for Dialog {
554 fn style(&mut self) -> &mut gpui::StyleRefinement {
555 &mut self.style
556 }
557}
558
559impl Dialog {
560 fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
561 let content_builder = self.content_builder.clone();
562 let style = self.style.clone();
563 let props = self.props.clone();
564 let button_props = self.button_props.clone();
565
566 gpui_base::DialogTrigger::new(trigger)
567 .on_open(move |window, cx| {
568 let content_builder = content_builder.clone();
569 let style = style.clone();
570 let props = props.clone();
571 let button_props = button_props.clone();
572 window.open_dialog(cx, move |dialog, _, _| {
573 dialog
574 .refine_style(&style)
575 .button_props(button_props.clone())
576 .with_props(props.clone())
577 .content({
578 let content_builder = content_builder.clone();
579 move |content, window, cx| {
580 if let Some(builder) = content_builder.clone() {
581 builder(content, window, cx)
582 } else {
583 content
584 }
585 }
586 })
587 });
588 })
589 .into_any_element()
590 }
591}
592
593impl RenderOnce for Dialog {
594 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
595 if let Some(trigger) = self.trigger.take() {
596 return self.render_trigger(trigger, window, cx);
597 }
598
599 let layer_ix = self.layer_ix;
600 let selection_scope = self.selection_scope;
601 let on_close = self.button_props.close_handler();
602 let on_ok = self.button_props.ok_handler();
603 let on_cancel = self.button_props.cancel_handler();
604
605 let window_paddings = crate::window_border::window_paddings(window);
606 let view_size = window.viewport_size()
607 - gpui::size(
608 window_paddings.left + window_paddings.right,
609 window_paddings.top + window_paddings.bottom,
610 );
611 let margin = cx.theme().spacing_tokens().lg;
617 let layer_offset = px(layer_ix as f32 * 16.);
618 let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + layer_offset;
619 let entrance = self.props.entrance;
620 let motion = cx.theme().motion_tokens();
621 let animated = entrance != DialogEntrance::None
622 && !cx.reduce_motion()
623 && (entrance == DialogEntrance::SlideDown || !motion.duration_normal.is_zero());
624 let travel = motion.distance_short.to_pixels(window.rem_size());
625 let width = self
626 .props
627 .width
628 .min((view_size.width - margin * 2.).max(px(0.)));
629 let x = (view_size.width - width) / 2.;
630 let max_height = (view_size.height - margin * 2. - layer_offset).max(px(0.));
631
632 let base_size = window.text_style().font_size;
633 let rem_size = window.rem_size();
634
635 let mut paddings = Edges::all(px(16.));
636 if let Some(pl) = self.style.padding.left {
637 paddings.left = pl.to_pixels(base_size, rem_size);
638 }
639 if let Some(pr) = self.style.padding.right {
640 paddings.right = pr.to_pixels(base_size, rem_size);
641 }
642 if let Some(pt) = self.style.padding.top {
643 paddings.top = pt.to_pixels(base_size, rem_size);
644 }
645 if let Some(pb) = self.style.padding.bottom {
646 paddings.bottom = pb.to_pixels(base_size, rem_size);
647 }
648
649 let animation = if entrance == DialogEntrance::SlideDown {
654 Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
655 1. / 3.,
656 0.72,
657 2. / 3.,
658 1.,
659 ))
660 } else {
661 let easing = motion.easing_enter.clone();
662 Animation::new(motion.duration_normal).with_easing(move |delta| easing.sample(delta))
663 };
664
665 anchored()
666 .position(point(window_paddings.left, window_paddings.top))
667 .snap_to_window()
668 .child(
669 div()
670 .id("dialog")
671 .test_support()
672 .occlude()
673 .w(view_size.width)
674 .h(view_size.height)
675 .child(
676 self.base
677 .take()
678 .expect("Dialog base host is always present")
679 .layer(
680 layer_ix,
681 (self.layer_ix + 1)
682 == WindowState::read(window, cx).active_dialogs.len(),
683 )
684 .focus_handle(self.focus_handle.clone())
685 .close_on_escape(self.props.keyboard)
686 .close_on_backdrop_press(self.props.overlay_closable)
687 .dismiss_below_y(TITLE_BAR_HEIGHT)
688 .when(self.props.overlay, |this| {
689 this.backdrop(
690 div()
691 .absolute()
692 .size_full()
693 .window_control_area(WindowControlArea::Drag)
694 .when(self.props.overlay_visible, |overlay| {
695 overlay.bg(overlay_color(true, cx))
696 }),
697 )
698 })
699 .on_ok(move |event, window, cx| on_ok(event, window, cx))
700 .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
701 .on_close(move |event, window, cx| on_close(event, window, cx))
702 .request_close(move |deferred, window, cx| {
703 if deferred {
704 Self::defer_close_dialog(window, cx);
705 } else {
706 window.close_dialog(cx);
707 }
708 })
709 .popup(
710 gpui_base::Positioner::corner(
711 Anchor::TopLeft,
712 point(window_paddings.left + x, window_paddings.top + y),
713 )
714 .margin(margin)
715 .child(
716 v_flex()
717 .id(layer_ix)
718 .test_support()
719 .debug_selector(move || format!("dialog-{layer_ix}"))
720 .bg(cx.theme().tokens.background)
721 .border_1()
722 .border_color(cx.theme().border)
723 .rounded(cx.theme().radius_lg)
724 .min_h_24()
725 .pt(paddings.top)
726 .pb(paddings.bottom)
727 .gap(paddings.top.max(px(8.)))
728 .refine_style(&self.style)
729 .px_0()
730 .occlude()
731 .w(width)
732 .when_some(self.props.max_width, |this, w| this.max_w(w))
733 .max_h(max_height)
734 .child(
735 v_flex()
736 .flex_1()
737 .overflow_hidden()
738 .gap_y_2()
739 .when_some(self.header, |this, header| {
740 this.child(
741 div()
742 .pl(paddings.left)
743 .pr(paddings.right)
744 .child(header),
745 )
746 })
747 .when_some(self.title, |this, title| {
748 this.child(
749 DialogTitle::new()
750 .pl(paddings.left)
751 .pr(paddings.right)
752 .child(title),
753 )
754 })
755 .when_some(self.content_builder, |this, builder| {
756 this.child(builder(
757 DialogContent::new()
758 .gap(paddings.bottom)
759 .pl(paddings.left)
760 .pr(paddings.right),
761 window,
762 cx,
763 ))
764 })
765 .when(!self.children.is_empty(), |this| {
766 this.child(
767 div().flex_1().overflow_hidden().child(
768 v_flex()
770 .size_full()
771 .overflow_y_scrollbar()
772 .pl(paddings.left)
773 .pr(paddings.right)
774 .children(self.children),
775 ),
776 )
777 }),
778 )
779 .when_some(self.footer, |this, footer| {
780 this.child(
781 div()
782 .pl(paddings.left)
783 .pr(paddings.right)
784 .child(footer),
785 )
786 })
787 .children(self.props.close_button.then(|| {
788 let top = (paddings.top - px(10.)).max(px(8.));
789 let right = (paddings.right - px(10.)).max(px(8.));
790
791 gpui_base::DialogClose::new()
792 .absolute()
793 .top(top)
794 .right(right)
795 .trigger(|button| {
796 Button::new("close")
797 .with_base(button)
798 .small()
799 .ghost()
800 .icon(IconName::Close)
801 })
802 }))
803 .map(|surface| {
804 if animated {
805 surface
806 .with_animation(
807 "slide-down-shadow",
808 animation.clone(),
809 |surface, delta| {
810 surface.shadow(dialog_shadow(delta))
811 },
812 )
813 .text_selection_scope(selection_scope)
814 .into_any_element()
815 } else {
816 surface
817 .shadow(dialog_shadow(1.))
818 .text_selection_scope(selection_scope)
819 .into_any_element()
820 }
821 })
822 .map(|surface| {
823 if animated && entrance == DialogEntrance::FadeSlide {
824 EntranceSurface::new(
825 surface,
826 travel,
827 window_paddings.top + margin,
828 )
829 .with_animation(
830 "dialog-settle",
831 animation.clone(),
832 |mut surface, delta| {
833 surface.progress = delta;
834 surface
835 },
836 )
837 .into_any_element()
838 } else {
839 surface
840 }
841 }),
842 )
843 .map(|positioner| {
844 if animated && entrance == DialogEntrance::SlideDown {
845 positioner
846 .with_animation(
847 "slide-down",
848 animation.clone(),
849 move |this, delta| {
850 this.position(point(
851 window_paddings.left + x,
852 window_paddings.top + y * delta,
853 ))
854 },
855 )
856 .into_any_element()
857 } else {
858 positioner.into_any_element()
859 }
860 }),
861 ),
862 )
863 .map(|layer| {
864 if animated {
865 layer
866 .with_animation("fade-in", animation, |layer, delta| {
867 layer.opacity(delta)
868 })
869 .into_any_element()
870 } else {
871 layer.into_any_element()
872 }
873 }),
874 )
875 .into_any_element()
876 }
877}
878
879#[cfg(test)]
880pub(crate) mod tests {
881 use super::*;
882 use gpui::{AppContext as _, Bounds, Context, Render, TestAppContext, VisualTestContext, size};
883
884 struct DialogHost;
885
886 impl Render for DialogHost {
887 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
888 div().size_full()
889 }
890 }
891
892 pub(crate) fn window(
895 cx: &mut TestAppContext,
896 window_size: gpui::Size<Pixels>,
897 ) -> &mut VisualTestContext {
898 cx.update(|cx| {
899 crate::init(cx);
900 cx.set_reduce_motion(true);
901 });
902 let (_, cx) = cx.add_window_view(|window, cx| {
903 let view = cx.new(|_| DialogHost);
904 crate::Root::new(view, window, cx)
905 });
906 cx.simulate_resize(window_size);
907 cx.update(|window, cx| window.draw(cx).clear(cx));
908 cx
909 }
910
911 fn open(
912 cx: &mut VisualTestContext,
913 build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
914 ) {
915 cx.update(|window, cx| window.open_dialog(cx, build));
916 cx.run_until_parked();
917 cx.update(|window, cx| window.draw(cx).clear(cx));
919 cx.update(|window, cx| window.draw(cx).clear(cx));
920 }
921
922 fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
923 let selector = ["dialog-0", "dialog-1"][layer_ix];
924 cx.debug_bounds(selector)
925 .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
926 }
927
928 #[gpui::test]
931 fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
932 let cx = window(cx, size(px(1000.), px(800.)));
933 open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
934
935 let bounds = surface(cx, 0);
936 assert_eq!(bounds.size.width, px(448.));
937 assert_eq!(bounds.origin.x, px(276.));
938 assert_eq!(bounds.origin.y, px(80.));
939 }
940
941 #[gpui::test]
946 fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
947 let viewport = size(px(400.), px(300.));
948 let cx = window(cx, viewport);
949 open(cx, |dialog, _, _| {
950 dialog
951 .w(px(800.))
952 .title("Too big")
953 .child(div().h(px(1000.)).child("tall body"))
954 .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
955 });
956
957 let bounds = surface(cx, 0);
958 let footer = cx.debug_bounds("footer-probe").unwrap();
959 let margin = px(16.);
960 assert!(
961 bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
962 "the dialog ran off the sides: {bounds:?}"
963 );
964 assert!(
965 bounds.bottom() <= viewport.height - margin,
966 "the dialog ran off the bottom: {bounds:?}"
967 );
968 assert_eq!(bounds.origin.y, margin);
969 assert!(
970 footer.bottom() <= bounds.bottom(),
971 "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
972 );
973 }
974
975 #[gpui::test]
977 fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
978 let cx = window(cx, size(px(400.), px(300.)));
979 cx.update(|_, cx| {
980 let dialog = Dialog::new(cx)
981 .button_props(DialogButtonProps::default().cancel_text("Keep"))
982 .button_props(DialogButtonProps::default().ok_text("Delete"))
983 .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
984
985 assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
986 assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
987 assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
988 });
989 }
990
991 #[gpui::test]
994 fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
995 let viewport = size(px(400.), px(300.));
996 let cx = window(cx, viewport);
997 open(cx, |dialog, _, _| {
998 dialog.title("First").child(div().h(px(1000.)))
999 });
1000 open(cx, |dialog, _, _| {
1001 dialog.title("Second").child(div().h(px(1000.)))
1002 });
1003
1004 let first = surface(cx, 0);
1005 let second = surface(cx, 1);
1006 assert_eq!(second.origin.y, first.origin.y + px(16.));
1007 assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
1008 assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
1009 assert!(second.size.height < first.size.height);
1010 }
1011
1012 #[gpui::test]
1013 fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
1014 cx.update(crate::init);
1015 cx.update(|cx| {
1016 assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
1017 for entrance in [
1018 DialogEntrance::SlideDown,
1019 DialogEntrance::Fade,
1020 DialogEntrance::FadeSlide,
1021 DialogEntrance::None,
1022 ] {
1023 assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
1024 }
1025 });
1026 }
1027
1028 #[gpui::test]
1029 fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
1030 for entrance in [
1031 DialogEntrance::SlideDown,
1032 DialogEntrance::Fade,
1033 DialogEntrance::FadeSlide,
1034 DialogEntrance::None,
1035 ] {
1036 let cx = window(cx, size(px(1000.), px(800.)));
1037 open(cx, move |dialog, _, _| {
1038 dialog
1039 .title("Entrance")
1040 .child("body")
1041 .entrance(entrance)
1042 .margin_top(px(300.))
1043 });
1044 assert_eq!(surface(cx, 0).top(), px(300.));
1045 }
1046 }
1047
1048 #[gpui::test]
1049 fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
1050 let cx = window(cx, size(px(1000.), px(800.)));
1051 cx.update(|_, cx| cx.set_reduce_motion(false));
1052 open(cx, |dialog, _, _| {
1053 dialog
1054 .title("Immediate")
1055 .child("body")
1056 .entrance(DialogEntrance::None)
1057 .margin_top(px(300.))
1058 });
1059 assert_eq!(surface(cx, 0).top(), px(300.));
1060 cx.simulate_keystrokes("escape");
1061 cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
1062 }
1063
1064 #[gpui::test]
1065 fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
1066 for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1067 let cx = window(cx, size(px(1000.), px(800.)));
1068 cx.update(|_, cx| {
1069 cx.set_reduce_motion(false);
1070 crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1071 });
1072 open(cx, move |dialog, _, _| {
1073 dialog
1074 .title("Immediate")
1075 .child("body")
1076 .entrance(entrance)
1077 .margin_top(px(300.))
1078 });
1079 assert_eq!(surface(cx, 0).top(), px(300.));
1080 }
1081 }
1082
1083 #[gpui::test]
1084 fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
1085 for entrance in [
1086 DialogEntrance::SlideDown,
1087 DialogEntrance::Fade,
1088 DialogEntrance::FadeSlide,
1089 DialogEntrance::None,
1090 ] {
1091 let cx = window(cx, size(px(1000.), px(800.)));
1092 cx.update(|window, cx| {
1093 let alert = crate::dialog::AlertDialog::new(cx)
1094 .title("Alert")
1095 .entrance(entrance);
1096 assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
1097 window.open_alert_dialog(cx, move |alert, _, _| {
1098 alert.title("Alert").entrance(entrance)
1099 });
1100 });
1101 cx.run_until_parked();
1102 cx.update(|window, cx| window.draw(cx).clear(cx));
1103 cx.update(|window, cx| window.draw(cx).clear(cx));
1104 assert_eq!(surface(cx, 0).top(), px(80.));
1105 }
1106 }
1107}