1use gpui_base::TestSupportExt as _;
2use std::{rc::Rc, sync::LazyLock, time::Duration};
3
4use gpui::{
5 Action, Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Edges,
6 FocusHandle, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
7 SharedString, 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, DialogDispatchAnchor, 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
112 DialogButton {
113 anchor_key: "dialog-ok-anchor",
114 button: Button::new("ok")
115 .label(ok_text)
116 .with_variant(self.ok_variant),
117 action: Rc::new(Confirm { secondary: false }),
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
128 DialogButton {
129 anchor_key: "dialog-cancel-anchor",
130 button: Button::new("cancel")
131 .label(cancel_text)
132 .with_variant(self.cancel_variant),
133 action: Rc::new(Cancel),
134 }
135 .into_any_element()
136 }
137}
138
139#[derive(IntoElement)]
142struct DialogButton {
143 anchor_key: &'static str,
145 button: Button,
146 action: Rc<dyn Action>,
147}
148
149impl RenderOnce for DialogButton {
150 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
151 let anchor = DialogDispatchAnchor::new(self.anchor_key, window, cx);
152 self.button
153 .child(anchor.element())
154 .on_click(move |_, window, cx| anchor.dispatch(&*self.action, window, cx))
155 }
156}
157
158type ContentBuilderFn = Rc<dyn Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static>;
159
160#[derive(Clone)]
161pub(crate) struct DialogProps {
162 width: Pixels,
163 max_width: Option<Pixels>,
164 margin_top: Option<Pixels>,
165 close_button: bool,
166
167 overlay: bool,
168 overlay_closable: bool,
169 pub(crate) overlay_visible: bool,
170 keyboard: bool,
171}
172
173impl Default for DialogProps {
174 fn default() -> Self {
175 Self {
176 margin_top: None,
177 width: px(448.),
178 max_width: None,
179 overlay: true,
180 keyboard: true,
181 overlay_visible: false,
182 close_button: true,
183 overlay_closable: true,
184 }
185 }
186}
187
188enum BaseDialogRoot {
189 Dialog(gpui_base::Dialog),
190 AlertDialog(gpui_base::AlertDialog),
191}
192
193macro_rules! map_base_root {
194 ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
195 match $self {
196 BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
197 BaseDialogRoot::AlertDialog(root) => {
198 BaseDialogRoot::AlertDialog(root.$method($($arg),*))
199 }
200 }
201 };
202}
203
204impl BaseDialogRoot {
205 fn layer(self, index: usize, topmost: bool) -> Self {
206 map_base_root!(self, layer(index, topmost))
207 }
208 fn focus_handle(self, focus: FocusHandle) -> Self {
209 map_base_root!(self, focus_handle(focus))
210 }
211 fn close_on_escape(self, value: bool) -> Self {
212 map_base_root!(self, close_on_escape(value))
213 }
214 fn close_on_backdrop_press(self, value: bool) -> Self {
215 match self {
216 Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
217 Self::AlertDialog(root) => Self::AlertDialog(root),
218 }
219 }
220 fn dismiss_below_y(self, value: Pixels) -> Self {
221 map_base_root!(self, dismiss_below_y(value))
222 }
223 fn backdrop(self, element: impl IntoElement) -> Self {
224 map_base_root!(self, backdrop(element))
225 }
226 fn popup(self, element: impl IntoElement) -> Self {
227 map_base_root!(self, popup(element))
228 }
229 fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
230 map_base_root!(self, on_ok(handler))
231 }
232 fn on_cancel(
233 self,
234 handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
235 ) -> Self {
236 map_base_root!(self, on_cancel(handler))
237 }
238 fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
239 map_base_root!(self, on_close(handler))
240 }
241 fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
242 map_base_root!(self, request_close(handler))
243 }
244}
245
246impl IntoElement for BaseDialogRoot {
247 type Element = <gpui_base::Dialog as IntoElement>::Element;
248 fn into_element(self) -> Self::Element {
249 match self {
250 Self::Dialog(root) => root.into_element(),
251 Self::AlertDialog(root) => root.into_element(),
252 }
253 }
254}
255
256#[derive(IntoElement)]
258pub struct Dialog {
259 base: Option<BaseDialogRoot>,
260 pub(crate) style: StyleRefinement,
261 children: Vec<AnyElement>,
262 trigger: Option<AnyElement>,
263 title: Option<AnyElement>,
264 pub(crate) header: Option<AnyElement>,
265 pub(crate) footer: Option<AnyElement>,
266 pub(crate) content_builder: Option<ContentBuilderFn>,
267 pub(crate) props: DialogProps,
268
269 pub(super) button_props: DialogButtonProps,
270
271 pub(crate) focus_handle: FocusHandle,
273 pub(crate) layer_ix: usize,
274 pub(crate) selection_scope: TextSelectionScopeId,
275}
276
277pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
278 if !overlay {
279 return hsla(0., 0., 0., 0.);
280 }
281
282 cx.theme().overlay
283}
284
285impl Dialog {
286 pub fn new(cx: &mut App) -> Self {
288 Self {
289 base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
290 focus_handle: cx.focus_handle(),
291 style: StyleRefinement::default(),
292 trigger: None,
293 title: None,
294 header: None,
295 footer: None,
296 content_builder: None,
297 props: DialogProps::default(),
298 children: Vec::new(),
299 layer_ix: 0,
300 selection_scope: TextSelectionScopeId::default(),
301 button_props: DialogButtonProps::default(),
302 }
303 }
304
305 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
309 self.trigger = Some(trigger.into_any_element());
310 self
311 }
312
313 pub fn content<F>(mut self, builder: F) -> Self
315 where
316 F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
317 {
318 self.content_builder = Some(Rc::new(builder));
319 self
320 }
321
322 pub fn title(mut self, title: impl IntoElement) -> Self {
324 self.title = Some(title.into_any_element());
325 self
326 }
327
328 pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
332 self.header = Some(header.into_any_element());
333 self
334 }
335
336 pub fn footer(mut self, footer: impl IntoElement) -> Self {
340 self.footer = Some(footer.into_any_element());
341 self
342 }
343
344 pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
346 self.button_props = button_props;
347 self
348 }
349 pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
350 self.base = Some(BaseDialogRoot::AlertDialog(base));
351 self.props.overlay_closable = false;
352 self
353 }
354
355 pub fn on_close(
359 mut self,
360 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
361 ) -> Self {
362 self.button_props.on_close = Rc::new(on_close);
363 self
364 }
365
366 pub fn on_ok(
370 mut self,
371 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
372 ) -> Self {
373 self.button_props = self.button_props.on_ok(on_ok);
374 self
375 }
376
377 pub fn on_cancel(
381 mut self,
382 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
383 ) -> Self {
384 self.button_props = self.button_props.on_cancel(on_cancel);
385 self
386 }
387
388 pub fn close_button(mut self, close_button: bool) -> Self {
390 self.props.close_button = close_button;
391 self
392 }
393
394 pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
396 self.props.margin_top = Some(margin_top.into());
397 self
398 }
399
400 pub fn w(mut self, width: impl Into<Pixels>) -> Self {
406 self.props.width = width.into();
407 self
408 }
409
410 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
414 self.props.width = width.into();
415 self
416 }
417
418 pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
420 self.props.max_width = Some(max_width.into());
421 self
422 }
423
424 pub fn overlay(mut self, overlay: bool) -> Self {
426 self.props.overlay = overlay;
427 self
428 }
429
430 pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
434 self.props.overlay_closable = overlay_closable;
435 self
436 }
437
438 pub fn keyboard(mut self, keyboard: bool) -> Self {
440 self.props.keyboard = keyboard;
441 self
442 }
443
444 pub(crate) fn has_overlay(&self) -> bool {
445 self.props.overlay
446 }
447
448 pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
449 self.props = props;
450 self
451 }
452
453 fn defer_close_dialog(window: &mut Window, cx: &mut App) {
454 Root::update(window, cx, |root, window, cx| {
455 root.defer_close_dialog(window, cx);
456 });
457 }
458}
459
460impl ParentElement for Dialog {
461 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
462 self.children.extend(elements);
463 }
464}
465
466impl Styled for Dialog {
467 fn style(&mut self) -> &mut gpui::StyleRefinement {
468 &mut self.style
469 }
470}
471
472impl Dialog {
473 fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
474 let content_builder = self.content_builder.clone();
475 let style = self.style.clone();
476 let props = self.props.clone();
477 let button_props = self.button_props.clone();
478
479 gpui_base::DialogTrigger::new(trigger)
480 .on_open(move |window, cx| {
481 let content_builder = content_builder.clone();
482 let style = style.clone();
483 let props = props.clone();
484 let button_props = button_props.clone();
485 window.open_dialog(cx, move |dialog, _, _| {
486 dialog
487 .refine_style(&style)
488 .button_props(button_props.clone())
489 .with_props(props.clone())
490 .content({
491 let content_builder = content_builder.clone();
492 move |content, window, cx| {
493 if let Some(builder) = content_builder.clone() {
494 builder(content, window, cx)
495 } else {
496 content
497 }
498 }
499 })
500 });
501 })
502 .into_any_element()
503 }
504}
505
506impl RenderOnce for Dialog {
507 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
508 if let Some(trigger) = self.trigger.take() {
509 return self.render_trigger(trigger, window, cx);
510 }
511
512 let layer_ix = self.layer_ix;
513 let selection_scope = self.selection_scope;
514 let on_close = self.button_props.on_close.clone();
515 let on_ok = self.button_props.on_ok.clone();
516 let on_cancel = self.button_props.on_cancel.clone();
517
518 let window_paddings = crate::window_border::window_paddings(window);
519 let view_size = window.viewport_size()
520 - gpui::size(
521 window_paddings.left + window_paddings.right,
522 window_paddings.top + window_paddings.bottom,
523 );
524 let margin = cx.theme().spacing_tokens().lg;
529 let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + px(layer_ix as f32 * 16.);
530 let width = self
531 .props
532 .width
533 .min((view_size.width - margin * 2.).max(px(0.)));
534 let x = (view_size.width - width) / 2.;
535 let max_height = (view_size.height - y - margin).max(px(0.));
536
537 let base_size = window.text_style().font_size;
538 let rem_size = window.rem_size();
539
540 let mut paddings = Edges::all(px(16.));
541 if let Some(pl) = self.style.padding.left {
542 paddings.left = pl.to_pixels(base_size, rem_size);
543 }
544 if let Some(pr) = self.style.padding.right {
545 paddings.right = pr.to_pixels(base_size, rem_size);
546 }
547 if let Some(pt) = self.style.padding.top {
548 paddings.top = pt.to_pixels(base_size, rem_size);
549 }
550 if let Some(pb) = self.style.padding.bottom {
551 paddings.bottom = pb.to_pixels(base_size, rem_size);
552 }
553
554 let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
559 1. / 3.,
560 0.72,
561 2. / 3.,
562 1.,
563 ));
564
565 anchored()
566 .position(point(window_paddings.left, window_paddings.top))
567 .snap_to_window()
568 .child(
569 div()
570 .id("dialog")
571 .test_support()
572 .occlude()
573 .w(view_size.width)
574 .h(view_size.height)
575 .child(
576 self.base
577 .take()
578 .expect("Dialog base host is always present")
579 .layer(
580 layer_ix,
581 (self.layer_ix + 1) == Root::read(window, cx).active_dialogs.len(),
582 )
583 .focus_handle(self.focus_handle.clone())
584 .close_on_escape(self.props.keyboard)
585 .close_on_backdrop_press(self.props.overlay_closable)
586 .dismiss_below_y(TITLE_BAR_HEIGHT)
587 .when(self.props.overlay, |this| {
588 this.backdrop(
589 div()
590 .absolute()
591 .size_full()
592 .window_control_area(WindowControlArea::Drag)
593 .when(self.props.overlay_visible, |overlay| {
594 overlay.bg(overlay_color(true, cx))
595 }),
596 )
597 })
598 .on_ok(move |event, window, cx| on_ok(event, window, cx))
599 .on_cancel(move |event, window, cx| on_cancel(event, window, cx))
600 .on_close(move |event, window, cx| on_close(event, window, cx))
601 .request_close(move |deferred, window, cx| {
602 if deferred {
603 Self::defer_close_dialog(window, cx);
604 } else {
605 window.close_dialog(cx);
606 }
607 })
608 .popup(
609 v_flex()
610 .id(layer_ix)
611 .test_support()
612 .debug_selector(move || format!("dialog-{layer_ix}"))
613 .bg(cx.theme().tokens.background)
614 .border_1()
615 .border_color(cx.theme().border)
616 .rounded(cx.theme().radius_lg)
617 .min_h_24()
618 .pt(paddings.top)
619 .pb(paddings.bottom)
620 .gap(paddings.top.max(px(8.)))
621 .refine_style(&self.style)
622 .px_0()
623 .absolute()
625 .occlude()
626 .relative()
627 .left(x)
628 .top(y)
629 .w(width)
630 .when_some(self.props.max_width, |this, w| this.max_w(w))
631 .max_h(max_height)
632 .child(
633 v_flex()
634 .flex_1()
635 .overflow_hidden()
636 .gap_y_2()
637 .when_some(self.header, |this, header| {
638 this.child(
639 div()
640 .pl(paddings.left)
641 .pr(paddings.right)
642 .child(header),
643 )
644 })
645 .when_some(self.title, |this, title| {
646 this.child(
647 DialogTitle::new()
648 .pl(paddings.left)
649 .pr(paddings.right)
650 .child(title),
651 )
652 })
653 .when_some(self.content_builder, |this, builder| {
654 this.child(builder(
655 DialogContent::new()
656 .gap(paddings.bottom)
657 .pl(paddings.left)
658 .pr(paddings.right),
659 window,
660 cx,
661 ))
662 })
663 .when(!self.children.is_empty(), |this| {
664 this.child(
665 div().flex_1().overflow_hidden().child(
666 v_flex()
668 .size_full()
669 .overflow_y_scrollbar()
670 .pl(paddings.left)
671 .pr(paddings.right)
672 .children(self.children),
673 ),
674 )
675 }),
676 )
677 .when_some(self.footer, |this, footer| {
678 this.child(
679 div()
680 .pl(paddings.left)
681 .pr(paddings.right)
682 .child(footer),
683 )
684 })
685 .children(self.props.close_button.then(|| {
686 let top = (paddings.top - px(10.)).max(px(8.));
687 let right = (paddings.right - px(10.)).max(px(8.));
688
689 gpui_base::DialogClose::new()
690 .absolute()
691 .top(top)
692 .right(right)
693 .trigger(|button| {
694 Button::new("close")
695 .with_base(button)
696 .small()
697 .ghost()
698 .icon(IconName::Close)
699 })
700 }))
701 .with_animation(
702 "slide-down",
703 animation.clone(),
704 move |this, delta| {
705 let shadow = vec![
707 BoxShadow {
708 color: hsla(0., 0., 0., 0.1 * delta),
709 offset: point(px(0.), px(20.)),
710 blur_radius: px(25.),
711 spread_radius: px(-5.),
712 inset: false,
713 },
714 BoxShadow {
715 color: hsla(0., 0., 0., 0.1 * delta),
716 offset: point(px(0.), px(8.)),
717 blur_radius: px(10.),
718 spread_radius: px(-6.),
719 inset: false,
720 },
721 ];
722 this.top(y * delta).shadow(shadow)
723 },
724 )
725 .text_selection_scope(selection_scope),
726 ),
727 )
728 .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
729 )
730 .into_any_element()
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use gpui::{AppContext as _, Bounds, Context, Render, TestAppContext, VisualTestContext, size};
738
739 struct DialogHost;
740
741 impl Render for DialogHost {
742 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
743 div()
744 .size_full()
745 .children(Root::render_dialog_layer(window, cx))
746 }
747 }
748
749 fn window(cx: &mut TestAppContext, window_size: gpui::Size<Pixels>) -> &mut VisualTestContext {
752 cx.update(|cx| {
753 crate::init(cx);
754 cx.set_reduce_motion(true);
755 });
756 let (_, cx) = cx.add_window_view(|window, cx| {
757 let view = cx.new(|_| DialogHost);
758 Root::new(view, window, cx)
759 });
760 cx.simulate_resize(window_size);
761 cx.update(|window, cx| window.draw(cx).clear(cx));
762 cx
763 }
764
765 fn open(
766 cx: &mut VisualTestContext,
767 build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
768 ) {
769 cx.update(|window, cx| window.open_dialog(cx, build));
770 cx.run_until_parked();
771 cx.update(|window, cx| window.draw(cx).clear(cx));
773 cx.update(|window, cx| window.draw(cx).clear(cx));
774 }
775
776 fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
777 let selector = ["dialog-0", "dialog-1"][layer_ix];
778 cx.debug_bounds(selector)
779 .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
780 }
781
782 #[gpui::test]
785 fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
786 let cx = window(cx, size(px(1000.), px(800.)));
787 open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
788
789 let bounds = surface(cx, 0);
790 assert_eq!(bounds.size.width, px(448.));
791 assert_eq!(bounds.origin.x, px(276.));
792 assert_eq!(bounds.origin.y, px(80.));
793 }
794
795 #[gpui::test]
799 fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
800 let viewport = size(px(400.), px(300.));
801 let cx = window(cx, viewport);
802 open(cx, |dialog, _, _| {
803 dialog
804 .w(px(800.))
805 .title("Too big")
806 .child(div().h(px(1000.)).child("tall body"))
807 .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
808 });
809
810 let bounds = surface(cx, 0);
811 let footer = cx.debug_bounds("footer-probe").unwrap();
812 let margin = px(16.);
813 assert!(
814 bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
815 "the dialog ran off the sides: {bounds:?}"
816 );
817 assert!(
818 bounds.bottom() <= viewport.height - margin,
819 "the dialog ran off the bottom: {bounds:?}"
820 );
821 assert_eq!(bounds.origin.y, viewport.height / 10.);
822 assert!(
823 footer.bottom() <= bounds.bottom(),
824 "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
825 );
826 }
827
828 #[gpui::test]
831 fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
832 let viewport = size(px(400.), px(300.));
833 let cx = window(cx, viewport);
834 open(cx, |dialog, _, _| {
835 dialog.title("First").child(div().h(px(1000.)))
836 });
837 open(cx, |dialog, _, _| {
838 dialog.title("Second").child(div().h(px(1000.)))
839 });
840
841 let first = surface(cx, 0);
842 let second = surface(cx, 1);
843 assert_eq!(second.origin.y, first.origin.y + px(16.));
844 assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
845 assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
846 assert!(second.size.height < first.size.height);
847 }
848}