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)
664 .with_easing(move |delta| easing.sample(delta).clamp(0., 1.))
665 };
666
667 anchored()
668 .position(point(window_paddings.left, window_paddings.top))
669 .snap_to_window()
670 .child(
671 div()
672 .id("dialog")
673 .test_support()
674 .occlude()
675 .w(view_size.width)
676 .h(view_size.height)
677 .child(
678 self.base
679 .take()
680 .expect("Dialog base host is always present")
681 .layer(
682 layer_ix,
683 (self.layer_ix + 1)
684 == WindowState::read(window, cx).active_dialogs.len(),
685 )
686 .focus_handle(self.focus_handle.clone())
687 .close_on_escape(self.props.keyboard)
688 .close_on_backdrop_press(self.props.overlay_closable)
689 .dismiss_below_y(TITLE_BAR_HEIGHT)
690 .when(self.props.overlay, |this| {
691 this.backdrop(
692 div()
693 .absolute()
694 .size_full()
695 .window_control_area(WindowControlArea::Drag)
696 .when(self.props.overlay_visible, |overlay| {
697 overlay.bg(overlay_color(true, cx))
698 }),
699 )
700 })
701 .on_ok(move |event, window, cx| on_ok(event, window, cx))
702 .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
703 .on_close(move |event, window, cx| on_close(event, window, cx))
704 .request_close(move |deferred, window, cx| {
705 if deferred {
706 Self::defer_close_dialog(window, cx);
707 } else {
708 window.close_dialog(cx);
709 }
710 })
711 .popup(
712 gpui_base::Positioner::corner(
713 Anchor::TopLeft,
714 point(window_paddings.left + x, window_paddings.top + y),
715 )
716 .margin(margin)
717 .child(
718 v_flex()
719 .id(layer_ix)
720 .test_support()
721 .debug_selector(move || format!("dialog-{layer_ix}"))
722 .bg(cx.theme().tokens.background)
723 .border_1()
724 .border_color(cx.theme().border)
725 .rounded(cx.theme().radius_lg)
726 .min_h_24()
727 .pt(paddings.top)
728 .pb(paddings.bottom)
729 .gap(paddings.top.max(px(8.)))
730 .refine_style(&self.style)
731 .px_0()
732 .occlude()
733 .w(width)
734 .when_some(self.props.max_width, |this, w| this.max_w(w))
735 .max_h(max_height)
736 .child(
737 v_flex()
738 .flex_1()
739 .overflow_hidden()
740 .gap_y_2()
741 .when_some(self.header, |this, header| {
742 this.child(
743 div()
744 .pl(paddings.left)
745 .pr(paddings.right)
746 .child(header),
747 )
748 })
749 .when_some(self.title, |this, title| {
750 this.child(
751 DialogTitle::new()
752 .pl(paddings.left)
753 .pr(paddings.right)
754 .child(title),
755 )
756 })
757 .when_some(self.content_builder, |this, builder| {
758 this.child(builder(
759 DialogContent::new()
760 .gap(paddings.bottom)
761 .pl(paddings.left)
762 .pr(paddings.right),
763 window,
764 cx,
765 ))
766 })
767 .when(!self.children.is_empty(), |this| {
768 this.child(
769 div().flex_1().overflow_hidden().child(
770 v_flex()
772 .size_full()
773 .overflow_y_scrollbar()
774 .pl(paddings.left)
775 .pr(paddings.right)
776 .children(self.children),
777 ),
778 )
779 }),
780 )
781 .when_some(self.footer, |this, footer| {
782 this.child(
783 div()
784 .pl(paddings.left)
785 .pr(paddings.right)
786 .child(footer),
787 )
788 })
789 .children(self.props.close_button.then(|| {
790 let top = (paddings.top - px(10.)).max(px(8.));
791 let right = (paddings.right - px(10.)).max(px(8.));
792
793 gpui_base::DialogClose::new()
794 .absolute()
795 .top(top)
796 .right(right)
797 .trigger(|button| {
798 Button::new("close")
799 .with_base(button)
800 .small()
801 .ghost()
802 .icon(IconName::Close)
803 })
804 }))
805 .map(|surface| {
806 if animated {
807 surface
808 .with_animation(
809 "slide-down-shadow",
810 animation.clone(),
811 |surface, delta| {
812 surface.shadow(dialog_shadow(delta))
813 },
814 )
815 .text_selection_scope(selection_scope)
816 .into_any_element()
817 } else {
818 surface
819 .shadow(dialog_shadow(1.))
820 .text_selection_scope(selection_scope)
821 .into_any_element()
822 }
823 })
824 .map(|surface| {
825 if animated && entrance == DialogEntrance::FadeSlide {
826 EntranceSurface::new(
827 surface,
828 travel,
829 window_paddings.top + margin,
830 )
831 .with_animation(
832 "dialog-settle",
833 animation.clone(),
834 |mut surface, delta| {
835 surface.progress = delta;
836 surface
837 },
838 )
839 .into_any_element()
840 } else {
841 surface
842 }
843 }),
844 )
845 .map(|positioner| {
846 if animated && entrance == DialogEntrance::SlideDown {
847 positioner
848 .with_animation(
849 "slide-down",
850 animation.clone(),
851 move |this, delta| {
852 this.position(point(
853 window_paddings.left + x,
854 window_paddings.top + y * delta,
855 ))
856 },
857 )
858 .into_any_element()
859 } else {
860 positioner.into_any_element()
861 }
862 }),
863 ),
864 )
865 .map(|layer| {
866 if animated {
867 layer
868 .with_animation("fade-in", animation, |layer, delta| {
869 layer.opacity(delta)
870 })
871 .into_any_element()
872 } else {
873 layer.into_any_element()
874 }
875 }),
876 )
877 .into_any_element()
878 }
879}
880
881#[cfg(test)]
882pub(crate) mod tests {
883 use super::*;
884 use gpui::{AppContext as _, Bounds, Context, Render, TestAppContext, VisualTestContext, size};
885
886 struct DialogHost;
887
888 impl Render for DialogHost {
889 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
890 div().size_full()
891 }
892 }
893
894 pub(crate) fn window(
897 cx: &mut TestAppContext,
898 window_size: gpui::Size<Pixels>,
899 ) -> &mut VisualTestContext {
900 cx.update(|cx| {
901 crate::init(cx);
902 cx.set_reduce_motion(true);
903 });
904 let (_, cx) = cx.add_window_view(|window, cx| {
905 let view = cx.new(|_| DialogHost);
906 crate::Root::new(view, window, cx)
907 });
908 cx.simulate_resize(window_size);
909 cx.update(|window, cx| window.draw(cx).clear(cx));
910 cx
911 }
912
913 fn open(
914 cx: &mut VisualTestContext,
915 build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
916 ) {
917 cx.update(|window, cx| window.open_dialog(cx, build));
918 cx.run_until_parked();
919 cx.update(|window, cx| window.draw(cx).clear(cx));
921 cx.update(|window, cx| window.draw(cx).clear(cx));
922 }
923
924 fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
925 let selector = ["dialog-0", "dialog-1"][layer_ix];
926 cx.debug_bounds(selector)
927 .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
928 }
929
930 #[gpui::test]
933 fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
934 let cx = window(cx, size(px(1000.), px(800.)));
935 open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
936
937 let bounds = surface(cx, 0);
938 assert_eq!(bounds.size.width, px(448.));
939 assert_eq!(bounds.origin.x, px(276.));
940 assert_eq!(bounds.origin.y, px(80.));
941 }
942
943 #[gpui::test]
948 fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
949 let viewport = size(px(400.), px(300.));
950 let cx = window(cx, viewport);
951 open(cx, |dialog, _, _| {
952 dialog
953 .w(px(800.))
954 .title("Too big")
955 .child(div().h(px(1000.)).child("tall body"))
956 .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
957 });
958
959 let bounds = surface(cx, 0);
960 let footer = cx.debug_bounds("footer-probe").unwrap();
961 let margin = px(16.);
962 assert!(
963 bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
964 "the dialog ran off the sides: {bounds:?}"
965 );
966 assert!(
967 bounds.bottom() <= viewport.height - margin,
968 "the dialog ran off the bottom: {bounds:?}"
969 );
970 assert_eq!(bounds.origin.y, margin);
971 assert!(
972 footer.bottom() <= bounds.bottom(),
973 "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
974 );
975 }
976
977 #[gpui::test]
979 fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
980 let cx = window(cx, size(px(400.), px(300.)));
981 cx.update(|_, cx| {
982 let dialog = Dialog::new(cx)
983 .button_props(DialogButtonProps::default().cancel_text("Keep"))
984 .button_props(DialogButtonProps::default().ok_text("Delete"))
985 .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
986
987 assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
988 assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
989 assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
990 });
991 }
992
993 #[gpui::test]
996 fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
997 let viewport = size(px(400.), px(300.));
998 let cx = window(cx, viewport);
999 open(cx, |dialog, _, _| {
1000 dialog.title("First").child(div().h(px(1000.)))
1001 });
1002 open(cx, |dialog, _, _| {
1003 dialog.title("Second").child(div().h(px(1000.)))
1004 });
1005
1006 let first = surface(cx, 0);
1007 let second = surface(cx, 1);
1008 assert_eq!(second.origin.y, first.origin.y + px(16.));
1009 assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
1010 assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
1011 assert!(second.size.height < first.size.height);
1012 }
1013
1014 #[gpui::test]
1015 fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
1016 cx.update(crate::init);
1017 cx.update(|cx| {
1018 assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
1019 for entrance in [
1020 DialogEntrance::SlideDown,
1021 DialogEntrance::Fade,
1022 DialogEntrance::FadeSlide,
1023 DialogEntrance::None,
1024 ] {
1025 assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
1026 }
1027 });
1028 }
1029
1030 #[gpui::test]
1031 fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
1032 for entrance in [
1033 DialogEntrance::SlideDown,
1034 DialogEntrance::Fade,
1035 DialogEntrance::FadeSlide,
1036 DialogEntrance::None,
1037 ] {
1038 let cx = window(cx, size(px(1000.), px(800.)));
1039 open(cx, move |dialog, _, _| {
1040 dialog
1041 .title("Entrance")
1042 .child("body")
1043 .entrance(entrance)
1044 .margin_top(px(300.))
1045 });
1046 assert_eq!(surface(cx, 0).top(), px(300.));
1047 }
1048 }
1049
1050 #[gpui::test]
1051 fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
1052 let cx = window(cx, size(px(1000.), px(800.)));
1053 cx.update(|_, cx| cx.set_reduce_motion(false));
1054 open(cx, |dialog, _, _| {
1055 dialog
1056 .title("Immediate")
1057 .child("body")
1058 .entrance(DialogEntrance::None)
1059 .margin_top(px(300.))
1060 });
1061 assert_eq!(surface(cx, 0).top(), px(300.));
1062 cx.simulate_keystrokes("escape");
1063 cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
1064 }
1065
1066 #[gpui::test]
1067 fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
1068 for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1069 let cx = window(cx, size(px(1000.), px(800.)));
1070 cx.update(|_, cx| {
1071 cx.set_reduce_motion(false);
1072 crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1073 });
1074 open(cx, move |dialog, _, _| {
1075 dialog
1076 .title("Immediate")
1077 .child("body")
1078 .entrance(entrance)
1079 .margin_top(px(300.))
1080 });
1081 assert_eq!(surface(cx, 0).top(), px(300.));
1082 }
1083 }
1084
1085 #[gpui::test]
1086 fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
1087 for entrance in [
1088 DialogEntrance::SlideDown,
1089 DialogEntrance::Fade,
1090 DialogEntrance::FadeSlide,
1091 DialogEntrance::None,
1092 ] {
1093 let cx = window(cx, size(px(1000.), px(800.)));
1094 cx.update(|window, cx| {
1095 let alert = crate::dialog::AlertDialog::new(cx)
1096 .title("Alert")
1097 .entrance(entrance);
1098 assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
1099 window.open_alert_dialog(cx, move |alert, _, _| {
1100 alert.title("Alert").entrance(entrance)
1101 });
1102 });
1103 cx.run_until_parked();
1104 cx.update(|window, cx| window.draw(cx).clear(cx));
1105 cx.update(|window, cx| window.draw(cx).clear(cx));
1106 assert_eq!(surface(cx, 0).top(), px(80.));
1107 }
1108 }
1109}