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, AnyElement, App, BoxShadow, ClickEvent, Edges, FocusHandle, Hsla,
8 InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
9 StyleRefinement, Styled, Window, WindowControlArea, anchored, div, hsla, point,
10 prelude::FluentBuilder, px,
11};
12use gpui_base::{ElementExt as _, TextSelectionScopeId, motion::Transition};
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 centered: bool,
209 close_button: bool,
210
211 overlay: bool,
212 overlay_closable: bool,
213 pub(crate) overlay_visible: bool,
214 keyboard: bool,
215 entrance: DialogEntrance,
216}
217
218impl Default for DialogProps {
219 fn default() -> Self {
220 Self {
221 margin_top: None,
222 centered: false,
223 width: px(448.),
224 max_width: None,
225 overlay: true,
226 keyboard: true,
227 overlay_visible: false,
228 close_button: true,
229 overlay_closable: true,
230 entrance: DialogEntrance::default(),
231 }
232 }
233}
234
235enum BaseDialogRoot {
236 Dialog(gpui_base::Dialog),
237 AlertDialog(gpui_base::AlertDialog),
238}
239
240macro_rules! map_base_root {
241 ($self:expr, $method:ident($($arg:expr),* $(,)?)) => {
242 match $self {
243 BaseDialogRoot::Dialog(root) => BaseDialogRoot::Dialog(root.$method($($arg),*)),
244 BaseDialogRoot::AlertDialog(root) => {
245 BaseDialogRoot::AlertDialog(root.$method($($arg),*))
246 }
247 }
248 };
249}
250
251impl BaseDialogRoot {
252 fn layer(self, index: usize, topmost: bool) -> Self {
253 map_base_root!(self, layer(index, topmost))
254 }
255 fn focus_handle(self, focus: FocusHandle) -> Self {
256 map_base_root!(self, focus_handle(focus))
257 }
258 fn close_on_escape(self, value: bool) -> Self {
259 map_base_root!(self, close_on_escape(value))
260 }
261 fn close_on_backdrop_press(self, value: bool) -> Self {
262 match self {
263 Self::Dialog(root) => Self::Dialog(root.close_on_backdrop_press(value)),
264 Self::AlertDialog(root) => Self::AlertDialog(root),
265 }
266 }
267 fn dismiss_below_y(self, value: Pixels) -> Self {
268 map_base_root!(self, dismiss_below_y(value))
269 }
270 fn backdrop(self, element: impl IntoElement) -> Self {
271 map_base_root!(self, backdrop(element))
272 }
273 fn popup(self, element: impl IntoElement) -> Self {
274 map_base_root!(self, popup(element))
275 }
276 fn on_ok(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static) -> Self {
277 map_base_root!(self, on_ok(handler))
278 }
279 fn on_cancel(
280 self,
281 handler: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
282 ) -> Self {
283 map_base_root!(self, on_cancel(handler))
284 }
285 fn on_close(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
286 map_base_root!(self, on_close(handler))
287 }
288 fn request_close(self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
289 map_base_root!(self, request_close(handler))
290 }
291}
292
293impl IntoElement for BaseDialogRoot {
294 type Element = <gpui_base::Dialog as IntoElement>::Element;
295 fn into_element(self) -> Self::Element {
296 match self {
297 Self::Dialog(root) => root.into_element(),
298 Self::AlertDialog(root) => root.into_element(),
299 }
300 }
301}
302
303#[derive(Clone, Copy, Debug)]
307pub(crate) struct DialogMotion {
308 pub(crate) visibility: f32,
309 pub(crate) closing: bool,
310}
311
312#[derive(IntoElement)]
314pub struct Dialog {
315 base: Option<BaseDialogRoot>,
316 pub(crate) style: StyleRefinement,
317 children: Vec<AnyElement>,
318 trigger: Option<AnyElement>,
319 title: Option<AnyElement>,
320 pub(crate) header: Option<AnyElement>,
321 pub(crate) footer: Option<AnyElement>,
322 pub(crate) content_builder: Option<ContentBuilderFn>,
323 pub(crate) props: DialogProps,
324
325 pub(super) button_props: DialogButtonProps,
326
327 pub(crate) focus_handle: FocusHandle,
329 pub(crate) layer_ix: usize,
330 pub(crate) selection_scope: TextSelectionScopeId,
331 pub(crate) motion: Option<DialogMotion>,
332 pub(crate) id: usize,
333}
334
335pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
336 if !overlay {
337 return hsla(0., 0., 0., 0.);
338 }
339
340 cx.theme().overlay
341}
342
343fn dialog_shadow(delta: f32) -> Vec<BoxShadow> {
344 vec![
345 BoxShadow {
346 color: hsla(0., 0., 0., 0.1 * delta),
347 offset: point(px(0.), px(20.)),
348 blur_radius: px(25.),
349 spread_radius: px(-5.),
350 inset: false,
351 },
352 BoxShadow {
353 color: hsla(0., 0., 0., 0.1 * delta),
354 offset: point(px(0.), px(8.)),
355 blur_radius: px(10.),
356 spread_radius: px(-6.),
357 inset: false,
358 },
359 ]
360}
361
362impl Dialog {
363 pub fn new(cx: &mut App) -> Self {
365 Self {
366 base: Some(BaseDialogRoot::Dialog(gpui_base::Dialog::new(cx))),
367 focus_handle: cx.focus_handle(),
368 style: StyleRefinement::default(),
369 trigger: None,
370 title: None,
371 header: None,
372 footer: None,
373 content_builder: None,
374 props: DialogProps::default(),
375 children: Vec::new(),
376 layer_ix: 0,
377 selection_scope: TextSelectionScopeId::default(),
378 button_props: DialogButtonProps::default(),
379 motion: None,
380 id: 0,
381 }
382 }
383
384 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
388 self.trigger = Some(trigger.into_any_element());
389 self
390 }
391
392 pub fn content<F>(mut self, builder: F) -> Self
394 where
395 F: Fn(DialogContent, &mut Window, &mut App) -> DialogContent + 'static,
396 {
397 self.content_builder = Some(Rc::new(builder));
398 self
399 }
400
401 pub fn title(mut self, title: impl IntoElement) -> Self {
403 self.title = Some(title.into_any_element());
404 self
405 }
406
407 pub(crate) fn header(mut self, header: impl IntoElement) -> Self {
409 self.header = Some(header.into_any_element());
410 self
411 }
412
413 pub fn footer(mut self, footer: impl IntoElement) -> Self {
422 self.footer = Some(footer.into_any_element());
423 self
424 }
425
426 pub fn button_props(mut self, button_props: DialogButtonProps) -> Self {
438 self.button_props.merge(button_props);
439 self
440 }
441
442 pub(crate) fn with_base_alert_dialog(mut self, base: gpui_base::AlertDialog) -> Self {
443 self.base = Some(BaseDialogRoot::AlertDialog(base));
444 self.props.overlay_closable = false;
445 self
446 }
447
448 pub fn on_close(
452 mut self,
453 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
454 ) -> Self {
455 self.button_props.on_close = Some(Rc::new(on_close));
456 self
457 }
458
459 pub fn on_ok(
463 mut self,
464 on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
465 ) -> Self {
466 self.button_props = self.button_props.on_ok(on_ok);
467 self
468 }
469
470 pub fn on_cancel(
474 mut self,
475 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
476 ) -> Self {
477 self.button_props = self.button_props.on_cancel(on_cancel);
478 self
479 }
480
481 pub fn close_button(mut self, close_button: bool) -> Self {
483 self.props.close_button = close_button;
484 self
485 }
486
487 pub fn margin_top(mut self, margin_top: impl Into<Pixels>) -> Self {
489 self.props.margin_top = Some(margin_top.into());
490 self
491 }
492
493 pub fn centered(mut self) -> Self {
496 self.props.centered = true;
497 self
498 }
499
500 pub fn w(mut self, width: impl Into<Pixels>) -> Self {
506 self.props.width = width.into();
507 self
508 }
509
510 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
514 self.props.width = width.into();
515 self
516 }
517
518 pub fn max_w(mut self, max_width: impl Into<Pixels>) -> Self {
520 self.props.max_width = Some(max_width.into());
521 self
522 }
523
524 pub fn overlay(mut self, overlay: bool) -> Self {
526 self.props.overlay = overlay;
527 self
528 }
529
530 pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
534 self.props.overlay_closable = overlay_closable;
535 self
536 }
537
538 pub fn keyboard(mut self, keyboard: bool) -> Self {
540 self.props.keyboard = keyboard;
541 self
542 }
543
544 pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
550 self.props.entrance = entrance;
551 self
552 }
553
554 pub(crate) fn exit_duration(&self, cx: &App) -> Duration {
559 if cx.reduce_motion() {
560 return Duration::ZERO;
561 }
562
563 match self.props.entrance {
564 DialogEntrance::None => Duration::ZERO,
565 DialogEntrance::SlideDown => *ANIMATION_DURATION,
566 _ => cx.theme().motion_tokens().duration_normal,
567 }
568 }
569
570 pub(crate) fn motion_transition(&self, cx: &App) -> Transition {
571 let transition = Transition::new(self.exit_duration(cx));
572 if self.props.entrance == DialogEntrance::SlideDown {
575 transition.ease(cubic_bezier(1. / 3., 0.72, 2. / 3., 1.))
576 } else {
577 let easing = cx.theme().motion_tokens().easing_enter.clone();
578 transition.ease(move |delta| easing.sample(delta).clamp(0., 1.))
579 }
580 }
581
582 pub(crate) fn has_overlay(&self) -> bool {
583 self.props.overlay
584 }
585
586 pub(crate) fn with_props(mut self, props: DialogProps) -> Self {
587 self.props = props;
588 self
589 }
590
591 fn defer_close_dialog(window: &mut Window, cx: &mut App) {
592 WindowState::update(window, cx, |root, window, cx| {
593 root.defer_close_dialog(window, cx);
594 });
595 }
596}
597
598impl ParentElement for Dialog {
599 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
600 self.children.extend(elements);
601 }
602}
603
604impl Styled for Dialog {
605 fn style(&mut self) -> &mut gpui::StyleRefinement {
606 &mut self.style
607 }
608}
609
610impl Dialog {
611 fn render_trigger(self, trigger: AnyElement, _: &mut Window, _: &mut App) -> AnyElement {
612 let content_builder = self.content_builder.clone();
613 let style = self.style.clone();
614 let props = self.props.clone();
615 let button_props = self.button_props.clone();
616
617 gpui_base::DialogTrigger::new(trigger)
618 .on_open(move |window, cx| {
619 let content_builder = content_builder.clone();
620 let style = style.clone();
621 let props = props.clone();
622 let button_props = button_props.clone();
623 window.open_dialog(cx, move |dialog, _, _| {
624 dialog
625 .refine_style(&style)
626 .button_props(button_props.clone())
627 .with_props(props.clone())
628 .content({
629 let content_builder = content_builder.clone();
630 move |content, window, cx| {
631 if let Some(builder) = content_builder.clone() {
632 builder(content, window, cx)
633 } else {
634 content
635 }
636 }
637 })
638 });
639 })
640 .into_any_element()
641 }
642}
643
644impl RenderOnce for Dialog {
645 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
646 if let Some(trigger) = self.trigger.take() {
647 return self.render_trigger(trigger, window, cx);
648 }
649
650 let layer_ix = self.layer_ix;
651 let selection_scope = self.selection_scope;
652 let on_close = self.button_props.close_handler();
653 let on_ok = self.button_props.ok_handler();
654 let on_cancel = self.button_props.cancel_handler();
655
656 let window_paddings = crate::window_border::window_paddings(window);
657 let view_size = window.viewport_size()
658 - gpui::size(
659 window_paddings.left + window_paddings.right,
660 window_paddings.top + window_paddings.bottom,
661 );
662 let margin = cx.theme().spacing_tokens().lg;
668 let layer_offset = px(layer_ix as f32 * 16.);
669 let (anchor, y) = if self.props.centered {
670 (Anchor::LeftCenter, view_size.height / 2.)
671 } else {
672 let y = self.props.margin_top.unwrap_or(view_size.height / 10.);
673 (Anchor::TopLeft, y)
674 };
675 let y = y + layer_offset;
676 let entrance = self.props.entrance;
677 let motion = cx.theme().motion_tokens();
678 let closing = self.motion.is_some_and(|motion| motion.closing);
679 let visibility = self
680 .motion
681 .map_or(1.0, |motion| motion.visibility.clamp(0., 1.));
682 let fade_overlay = closing && self.props.overlay_visible;
683 let travel = motion.distance_short.to_pixels(window.rem_size());
684 let width = self
685 .props
686 .width
687 .min((view_size.width - margin * 2.).max(px(0.)));
688 let x = (view_size.width - width) / 2.;
689 let max_height = (view_size.height - margin * 2. - layer_offset).max(px(0.));
690
691 let base_size = window.text_style().font_size;
692 let rem_size = window.rem_size();
693
694 let mut paddings = Edges::all(px(16.));
695 if let Some(pl) = self.style.padding.left {
696 paddings.left = pl.to_pixels(base_size, rem_size);
697 }
698 if let Some(pr) = self.style.padding.right {
699 paddings.right = pr.to_pixels(base_size, rem_size);
700 }
701 if let Some(pt) = self.style.padding.top {
702 paddings.top = pt.to_pixels(base_size, rem_size);
703 }
704 if let Some(pb) = self.style.padding.bottom {
705 paddings.bottom = pb.to_pixels(base_size, rem_size);
706 }
707
708 let topmost = WindowState::read(window, cx)
711 .active_dialogs
712 .iter()
713 .rposition(|dialog| !dialog.closing);
714 let is_topmost = !closing && topmost == Some(layer_ix);
715
716 anchored()
717 .position(point(window_paddings.left, window_paddings.top))
718 .snap_to_window()
719 .child(
720 div()
721 .id("dialog")
722 .test_support()
723 .when(!closing, |this| this.occlude())
726 .w(view_size.width)
727 .h(view_size.height)
728 .when(fade_overlay, |this| {
729 this.child(div().absolute().inset_0().bg(overlay_color(true, cx)))
730 })
731 .child(
732 self.base
733 .take()
734 .expect("Dialog base host is always present")
735 .layer(self.id, is_topmost)
736 .focus_handle(self.focus_handle.clone())
737 .close_on_escape(self.props.keyboard && !closing)
738 .close_on_backdrop_press(self.props.overlay_closable && !closing)
739 .dismiss_below_y(TITLE_BAR_HEIGHT)
740 .when(self.props.overlay && !closing, |this| {
741 this.backdrop(
742 div()
743 .absolute()
744 .size_full()
745 .window_control_area(WindowControlArea::Drag)
746 .when(self.props.overlay_visible, |overlay| {
747 overlay.bg(overlay_color(true, cx))
748 }),
749 )
750 })
751 .on_ok(move |event, window, cx| !closing && on_ok(event, window, cx))
752 .on_cancel(move |event, window, cx| {
753 !closing && on_cancel(event, window, cx)
754 })
755 .on_close(move |event, window, cx| on_close(event, window, cx))
756 .request_close(move |deferred, window, cx| {
757 if closing {
760 return;
761 }
762 if deferred {
763 Self::defer_close_dialog(window, cx);
764 } else {
765 window.close_dialog(cx);
766 }
767 })
768 .popup(
769 gpui_base::Positioner::corner(
770 anchor,
771 point(window_paddings.left + x, window_paddings.top + y),
772 )
773 .margin(margin)
774 .child(
775 v_flex()
776 .id("surface")
777 .test_support()
778 .debug_selector(move || format!("dialog-{layer_ix}"))
779 .bg(cx.theme().tokens.background)
780 .border_1()
781 .border_color(cx.theme().border)
782 .rounded(cx.theme().radius_lg)
783 .min_h_24()
784 .pt(paddings.top)
785 .pb(paddings.bottom)
786 .gap(paddings.top.max(px(8.)))
787 .refine_style(&self.style)
788 .px_0()
789 .occlude()
790 .w(width)
791 .when_some(self.props.max_width, |this, w| this.max_w(w))
792 .max_h(max_height)
793 .child(
794 v_flex()
795 .flex_1()
796 .overflow_hidden()
797 .gap_y_2()
798 .when_some(self.header, |this, header| {
799 this.child(
800 div()
801 .pl(paddings.left)
802 .pr(paddings.right)
803 .child(header),
804 )
805 })
806 .when_some(self.title, |this, title| {
807 this.child(
808 DialogTitle::new()
809 .pl(paddings.left)
810 .pr(paddings.right)
811 .child(title),
812 )
813 })
814 .when_some(self.content_builder, |this, builder| {
815 this.child(builder(
816 DialogContent::new()
817 .gap(paddings.bottom)
818 .pl(paddings.left)
819 .pr(paddings.right),
820 window,
821 cx,
822 ))
823 })
824 .when(!self.children.is_empty(), |this| {
825 this.child(
826 div().flex_1().overflow_hidden().child(
827 v_flex()
829 .size_full()
830 .overflow_y_scrollbar()
831 .pl(paddings.left)
832 .pr(paddings.right)
833 .children(self.children),
834 ),
835 )
836 }),
837 )
838 .when_some(self.footer, |this, footer| {
839 this.child(
840 div()
841 .pl(paddings.left)
842 .pr(paddings.right)
843 .child(footer),
844 )
845 })
846 .children(self.props.close_button.then(|| {
847 let top = (paddings.top - px(10.)).max(px(8.));
848 let right = (paddings.right - px(10.)).max(px(8.));
849
850 gpui_base::DialogClose::new()
851 .absolute()
852 .top(top)
853 .right(right)
854 .trigger(|button| {
855 Button::new("close")
856 .with_base(button)
857 .small()
858 .ghost()
859 .icon(IconName::Close)
860 .accessibility_label("Close")
861 })
862 }))
863 .when(closing, |surface| {
864 surface
867 .capture_any_mouse_down(|_, _, cx| {
868 cx.stop_propagation()
869 })
870 .capture_any_mouse_up(|_, _, cx| {
871 cx.stop_propagation()
872 })
873 .capture_key_down(|_, window, cx| {
874 window.prevent_default();
875 cx.stop_propagation();
876 })
877 .capture_key_up(|_, _, cx| cx.stop_propagation())
878 .child(
879 div()
880 .absolute()
881 .inset_0()
882 .occlude()
883 .on_any_mouse_down(|_, _, cx| {
884 cx.stop_propagation()
885 })
886 .on_scroll(|_, _, cx| {
887 cx.stop_propagation()
888 }),
889 )
890 })
891 .opacity(visibility)
894 .shadow(dialog_shadow(visibility))
895 .text_selection_scope(selection_scope)
896 .map(|surface| {
897 if entrance == DialogEntrance::FadeSlide {
898 let mut surface = EntranceSurface::new(
899 surface.into_any_element(),
900 travel,
901 window_paddings.top + margin,
902 );
903 surface.progress = visibility;
904 surface.into_any_element()
905 } else {
906 surface.into_any_element()
907 }
908 }),
909 )
910 .when(
911 entrance == DialogEntrance::SlideDown,
912 |positioner| {
913 positioner.position(point(
914 window_paddings.left + x,
915 window_paddings.top + y * visibility,
916 ))
917 },
918 ),
919 ),
920 )
921 .opacity(visibility),
922 )
923 .into_any_element()
924 }
925}
926
927#[cfg(test)]
928pub(crate) mod tests {
929 use super::*;
930 use gpui::{
931 AppContext as _, Bounds, Context, Render, StatefulInteractiveElement as _, TestAppContext,
932 VisualTestContext, size,
933 };
934 use std::{cell::Cell, rc::Rc};
935
936 struct DialogHost {
937 clicks: Rc<Cell<usize>>,
938 }
939
940 impl Render for DialogHost {
941 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
942 let clicks = self.clicks.clone();
943 div().size_full().child(
944 div()
947 .id("background-target")
948 .absolute()
949 .top_0()
950 .left_0()
951 .w_full()
952 .h(px(200.))
953 .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
954 )
955 }
956 }
957
958 pub(crate) fn window(
961 cx: &mut TestAppContext,
962 window_size: gpui::Size<Pixels>,
963 ) -> &mut VisualTestContext {
964 let (cx, _) = window_with_click_count(cx, window_size);
965 cx
966 }
967
968 fn window_with_click_count(
971 cx: &mut TestAppContext,
972 window_size: gpui::Size<Pixels>,
973 ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
974 cx.update(|cx| {
975 crate::init(cx);
976 cx.set_reduce_motion(true);
977 });
978 let clicks = Rc::new(Cell::new(0));
979 let counter = clicks.clone();
980 let (_, cx) = cx.add_window_view(|window, cx| {
981 let view = cx.new(|_| DialogHost { clicks: counter });
982 crate::Root::new(view, window, cx)
983 });
984 cx.simulate_resize(window_size);
985 cx.update(|window, cx| window.draw(cx).clear(cx));
986 (cx, clicks)
987 }
988
989 fn open(
990 cx: &mut VisualTestContext,
991 build: impl Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static,
992 ) {
993 cx.update(|window, cx| window.open_dialog(cx, build));
994 cx.run_until_parked();
995 cx.update(|window, cx| window.draw(cx).clear(cx));
997 cx.update(|window, cx| window.draw(cx).clear(cx));
998 }
999
1000 fn frame(cx: &mut VisualTestContext) {
1003 cx.update(|window, _| window.refresh());
1004 cx.run_until_parked();
1005 }
1006
1007 fn surface(cx: &mut VisualTestContext, layer_ix: usize) -> Bounds<Pixels> {
1008 let selector = ["dialog-0", "dialog-1"][layer_ix];
1009 cx.debug_bounds(selector)
1010 .unwrap_or_else(|| panic!("dialog layer {layer_ix} was not painted"))
1011 }
1012
1013 #[gpui::test]
1016 fn a_dialog_that_fits_keeps_its_default_width_and_top_offset(cx: &mut TestAppContext) {
1017 let cx = window(cx, size(px(1000.), px(800.)));
1018 open(cx, |dialog, _, _| dialog.title("Fits").child("body"));
1019
1020 let bounds = surface(cx, 0);
1021 assert_eq!(bounds.size.width, px(448.));
1022 assert_eq!(bounds.origin.x, px(276.));
1023 assert_eq!(bounds.origin.y, px(80.));
1024 }
1025
1026 #[gpui::test]
1031 fn a_dialog_larger_than_the_window_stays_inside_it(cx: &mut TestAppContext) {
1032 let viewport = size(px(400.), px(300.));
1033 let cx = window(cx, viewport);
1034 open(cx, |dialog, _, _| {
1035 dialog
1036 .w(px(800.))
1037 .title("Too big")
1038 .child(div().h(px(1000.)).child("tall body"))
1039 .footer(div().h(px(32.)).debug_selector(|| "footer-probe".into()))
1040 });
1041
1042 let bounds = surface(cx, 0);
1043 let footer = cx.debug_bounds("footer-probe").unwrap();
1044 let margin = px(16.);
1045 assert!(
1046 bounds.origin.x >= margin && bounds.right() <= viewport.width - margin,
1047 "the dialog ran off the sides: {bounds:?}"
1048 );
1049 assert!(
1050 bounds.bottom() <= viewport.height - margin,
1051 "the dialog ran off the bottom: {bounds:?}"
1052 );
1053 assert_eq!(bounds.origin.y, margin);
1054 assert!(
1055 footer.bottom() <= bounds.bottom(),
1056 "the footer was clipped below the dialog: footer {footer:?}, dialog {bounds:?}"
1057 );
1058 }
1059
1060 #[gpui::test]
1062 fn dialog_button_props_merge_with_what_the_dialog_already_carries(cx: &mut TestAppContext) {
1063 let cx = window(cx, size(px(400.), px(300.)));
1064 cx.update(|_, cx| {
1065 let dialog = Dialog::new(cx)
1066 .button_props(DialogButtonProps::default().cancel_text("Keep"))
1067 .button_props(DialogButtonProps::default().ok_text("Delete"))
1068 .button_props(DialogButtonProps::default().ok_variant(ButtonVariant::Danger));
1069
1070 assert_eq!(dialog.button_props.ok_text.as_deref(), Some("Delete"));
1071 assert_eq!(dialog.button_props.cancel_text.as_deref(), Some("Keep"));
1072 assert_eq!(dialog.button_props.ok_variant, Some(ButtonVariant::Danger));
1073 });
1074 }
1075
1076 #[gpui::test]
1079 fn stacked_dialogs_each_fit_the_window(cx: &mut TestAppContext) {
1080 let viewport = size(px(400.), px(300.));
1081 let cx = window(cx, viewport);
1082 open(cx, |dialog, _, _| {
1083 dialog.title("First").child(div().h(px(1000.)))
1084 });
1085 open(cx, |dialog, _, _| {
1086 dialog.title("Second").child(div().h(px(1000.)))
1087 });
1088
1089 let first = surface(cx, 0);
1090 let second = surface(cx, 1);
1091 assert_eq!(second.origin.y, first.origin.y + px(16.));
1092 assert!(first.bottom() <= viewport.height - px(16.), "{first:?}");
1093 assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
1094 assert!(second.size.height < first.size.height);
1095 }
1096
1097 #[gpui::test]
1098 fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
1099 cx.update(crate::init);
1100 cx.update(|cx| {
1101 assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
1102 for entrance in [
1103 DialogEntrance::SlideDown,
1104 DialogEntrance::Fade,
1105 DialogEntrance::FadeSlide,
1106 DialogEntrance::None,
1107 ] {
1108 assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
1109 }
1110 });
1111 }
1112
1113 #[gpui::test]
1114 fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
1115 for entrance in [
1116 DialogEntrance::SlideDown,
1117 DialogEntrance::Fade,
1118 DialogEntrance::FadeSlide,
1119 DialogEntrance::None,
1120 ] {
1121 let cx = window(cx, size(px(1000.), px(800.)));
1122 open(cx, move |dialog, _, _| {
1123 dialog
1124 .title("Entrance")
1125 .child("body")
1126 .entrance(entrance)
1127 .margin_top(px(300.))
1128 });
1129 assert_eq!(surface(cx, 0).top(), px(300.));
1130 }
1131 }
1132
1133 #[gpui::test]
1134 fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
1135 let cx = window(cx, size(px(1000.), px(800.)));
1136 cx.update(|_, cx| cx.set_reduce_motion(false));
1137 open(cx, |dialog, _, _| {
1138 dialog
1139 .title("Immediate")
1140 .child("body")
1141 .entrance(DialogEntrance::None)
1142 .margin_top(px(300.))
1143 });
1144 assert_eq!(surface(cx, 0).top(), px(300.));
1145 cx.simulate_keystrokes("escape");
1146 cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
1147 }
1148
1149 #[gpui::test]
1152 fn closing_plays_the_entrance_backwards_before_the_dialog_unmounts(cx: &mut TestAppContext) {
1153 let cx = window(cx, size(px(1000.), px(800.)));
1154 cx.update(|_, cx| cx.set_reduce_motion(false));
1155 open(cx, |dialog, _, _| {
1156 dialog.title("Closing").child("body").margin_top(px(300.))
1157 });
1158
1159 cx.background_executor.advance_clock(*ANIMATION_DURATION);
1160 frame(cx);
1161 cx.update(|window, cx| window.close_dialog(cx));
1162 frame(cx);
1163 cx.update(|window, cx| {
1166 assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1);
1167 assert!(!window.has_active_dialog(cx));
1168 });
1169 assert_eq!(surface(cx, 0).top(), px(300.));
1170
1171 cx.background_executor
1173 .advance_clock(*ANIMATION_DURATION / 2);
1174 frame(cx);
1175 cx.update(|window, cx| assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1));
1176 let halfway = surface(cx, 0).top();
1177 assert!(
1178 halfway > px(0.) && halfway < px(300.),
1179 "the closing dialog should slide back up: {halfway:?}"
1180 );
1181
1182 cx.background_executor
1184 .advance_clock(*ANIMATION_DURATION / 2 + Duration::from_millis(1));
1185 frame(cx);
1186 cx.update(|window, cx| {
1187 assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1188 assert!(!window.has_active_dialog(cx));
1189 });
1190 assert!(cx.debug_bounds("dialog-0").is_none());
1191 }
1192
1193 #[gpui::test]
1194 fn reduced_motion_closes_in_a_single_frame(cx: &mut TestAppContext) {
1195 let cx = window(cx, size(px(1000.), px(800.)));
1196 open(cx, |dialog, _, _| dialog.title("Reduced").child("body"));
1197 cx.update(|window, cx| window.close_dialog(cx));
1198 cx.update(|window, cx| {
1199 assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1200 assert!(!window.has_active_dialog(cx));
1201 });
1202 }
1203
1204 #[gpui::test]
1205 fn zero_theme_duration_closes_fade_entrances_immediately(cx: &mut TestAppContext) {
1206 for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1207 let cx = window(cx, size(px(1000.), px(800.)));
1208 cx.update(|_, cx| {
1209 cx.set_reduce_motion(false);
1210 crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1211 });
1212 open(cx, move |dialog, _, _| {
1213 dialog.title("Immediate").child("body").entrance(entrance)
1214 });
1215 cx.update(|window, cx| window.close_dialog(cx));
1216 cx.update(|window, cx| {
1217 assert!(WindowState::read(window, cx).active_dialogs.is_empty());
1218 assert!(!window.has_active_dialog(cx));
1219 });
1220 }
1221 }
1222
1223 #[gpui::test]
1226 fn reopening_during_the_exit_keeps_the_new_dialog(cx: &mut TestAppContext) {
1227 let cx = window(cx, size(px(1000.), px(800.)));
1228 cx.update(|_, cx| cx.set_reduce_motion(false));
1229 open(cx, |dialog, _, _| dialog.title("First").child("body"));
1230 cx.background_executor.advance_clock(*ANIMATION_DURATION);
1231 frame(cx);
1232 cx.update(|window, cx| window.close_dialog(cx));
1233 frame(cx);
1234
1235 open(cx, |dialog, _, _| dialog.title("Second").child("body"));
1236 cx.update(|window, cx| {
1237 let state = WindowState::read(window, cx);
1238 assert_eq!(state.active_dialogs.len(), 2);
1239 assert!(state.active_dialogs[0].closing);
1240 assert!(!state.active_dialogs[1].closing);
1241 assert!(
1242 state.active_dialogs[1].focus_handle().is_focused(window),
1243 "the reopened dialog owns focus while the old one leaves"
1244 );
1245 });
1246
1247 cx.background_executor
1248 .advance_clock(*ANIMATION_DURATION + Duration::from_millis(1));
1249 frame(cx);
1250 frame(cx);
1252 cx.update(|window, cx| {
1253 let state = WindowState::read(window, cx);
1254 assert_eq!(state.active_dialogs.len(), 1);
1255 assert!(!state.active_dialogs[0].closing);
1256 assert!(state.active_dialogs[0].focus_handle().is_focused(window));
1257 assert!(window.has_active_dialog(cx));
1258 });
1259 assert_eq!(surface(cx, 0).top(), px(80.));
1260 assert!(cx.debug_bounds("dialog-1").is_none());
1261 }
1262
1263 #[gpui::test]
1266 fn closing_the_top_dialog_leaves_the_one_below_mounted(cx: &mut TestAppContext) {
1267 let cx = window(cx, size(px(1000.), px(800.)));
1268 cx.update(|_, cx| cx.set_reduce_motion(false));
1269 open(cx, |dialog, _, _| dialog.title("First").child("body"));
1270 open(cx, |dialog, _, _| dialog.title("Second").child("body"));
1271
1272 cx.background_executor.advance_clock(*ANIMATION_DURATION);
1273 frame(cx);
1274 cx.update(|window, cx| window.close_dialog(cx));
1275 frame(cx);
1276 cx.update(|window, cx| {
1277 assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 2);
1278 assert!(window.has_active_dialog(cx));
1279 });
1280 assert!(
1281 cx.debug_bounds("dialog-0").is_some(),
1282 "the lower dialog stays"
1283 );
1284 assert!(
1285 cx.debug_bounds("dialog-1").is_some(),
1286 "the closing dialog still paints its exit"
1287 );
1288
1289 cx.background_executor
1290 .advance_clock(*ANIMATION_DURATION + Duration::from_millis(1));
1291 frame(cx);
1292 cx.update(|window, cx| {
1293 assert_eq!(WindowState::read(window, cx).active_dialogs.len(), 1);
1294 assert!(window.has_active_dialog(cx));
1295 });
1296 assert!(cx.debug_bounds("dialog-0").is_some());
1297 assert!(cx.debug_bounds("dialog-1").is_none());
1298 }
1299
1300 #[gpui::test]
1303 fn closing_dialog_does_not_block_input_beneath_it(cx: &mut TestAppContext) {
1304 let (cx, clicks) = window_with_click_count(cx, size(px(1000.), px(800.)));
1305 cx.update(|_, cx| cx.set_reduce_motion(false));
1306 open(cx, |dialog, _, _| {
1307 dialog
1308 .title("Closing")
1309 .child("body")
1310 .margin_top(px(300.))
1311 .overlay_closable(false)
1312 });
1313
1314 let backdrop_point = point(px(100.), px(60.));
1317 cx.simulate_click(backdrop_point, gpui::Modifiers::default());
1318 assert_eq!(clicks.get(), 0, "an open dialog blocked the application");
1319
1320 cx.background_executor.advance_clock(*ANIMATION_DURATION);
1321 frame(cx);
1322 cx.update(|window, cx| window.close_dialog(cx));
1323 frame(cx);
1324 cx.simulate_click(backdrop_point, gpui::Modifiers::default());
1325 assert_eq!(
1326 clicks.get(),
1327 1,
1328 "the closing dialog swallowed a press meant for the application"
1329 );
1330 }
1331
1332 #[gpui::test]
1333 fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
1334 for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
1335 let cx = window(cx, size(px(1000.), px(800.)));
1336 cx.update(|_, cx| {
1337 cx.set_reduce_motion(false);
1338 crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
1339 });
1340 open(cx, move |dialog, _, _| {
1341 dialog
1342 .title("Immediate")
1343 .child("body")
1344 .entrance(entrance)
1345 .margin_top(px(300.))
1346 });
1347 assert_eq!(surface(cx, 0).top(), px(300.));
1348 }
1349 }
1350
1351 #[gpui::test]
1352 fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
1353 for entrance in [
1354 DialogEntrance::SlideDown,
1355 DialogEntrance::Fade,
1356 DialogEntrance::FadeSlide,
1357 DialogEntrance::None,
1358 ] {
1359 let cx = window(cx, size(px(1000.), px(800.)));
1360 cx.update(|window, cx| {
1361 let alert = crate::dialog::AlertDialog::new(cx)
1362 .title("Alert")
1363 .entrance(entrance);
1364 assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
1365 window.open_alert_dialog(cx, move |alert, _, _| {
1366 alert.title("Alert").entrance(entrance)
1367 });
1368 });
1369 cx.run_until_parked();
1370 cx.update(|window, cx| window.draw(cx).clear(cx));
1371 cx.update(|window, cx| window.draw(cx).clear(cx));
1372 assert_eq!(surface(cx, 0).top(), px(80.));
1373 }
1374 }
1375
1376 #[gpui::test]
1379 fn closing_fades_the_dialog_surface(cx: &mut TestAppContext) {
1380 let cx = window(cx, size(px(1000.), px(800.)));
1381 cx.update(|_, cx| cx.set_reduce_motion(false));
1382 open(cx, |dialog, _, _| {
1383 dialog
1384 .title("Fading")
1385 .child("body")
1386 .margin_top(px(300.))
1387 .entrance(DialogEntrance::Fade)
1388 });
1389 cx.background_executor.advance_clock(*ANIMATION_DURATION);
1390 frame(cx);
1391 cx.update(|window, cx| window.close_dialog(cx));
1392 frame(cx);
1393 assert_eq!(
1394 surface_alpha(cx),
1395 1.0,
1396 "the exit should start fully visible"
1397 );
1398
1399 cx.background_executor
1400 .advance_clock(Duration::from_millis(90));
1401 frame(cx);
1402 let alpha = surface_alpha(cx);
1403 assert!(
1404 alpha > 0.0 && alpha < 1.0,
1405 "the closing surface should fade, but its background is {alpha}"
1406 );
1407 }
1408
1409 #[gpui::test]
1410 fn closing_during_entrance_reverses_from_the_current_sample(cx: &mut TestAppContext) {
1411 for entrance in [
1412 DialogEntrance::SlideDown,
1413 DialogEntrance::Fade,
1414 DialogEntrance::FadeSlide,
1415 ] {
1416 let cx = window(cx, size(px(1000.), px(800.)));
1417 cx.update(|_, cx| cx.set_reduce_motion(false));
1418 open(cx, move |dialog, _, _| {
1419 dialog
1420 .title("Interrupted")
1421 .child("body")
1422 .margin_top(px(300.))
1423 .entrance(entrance)
1424 });
1425 cx.background_executor
1426 .advance_clock(Duration::from_millis(40));
1427 frame(cx);
1428 let before = surface(cx, 0);
1429 let alpha = surface_alpha(cx);
1430 assert!(alpha > 0. && alpha < 1.);
1431 cx.update(|window, cx| window.close_dialog(cx));
1432 frame(cx);
1433 assert_eq!(surface(cx, 0), before, "{entrance:?} jumped on close");
1434 assert!((surface_alpha(cx) - alpha).abs() < 0.001);
1435 cx.background_executor
1436 .advance_clock(Duration::from_millis(20));
1437 frame(cx);
1438 assert!(surface_alpha(cx) < alpha);
1439 assert!(surface(cx, 0).top() <= before.top());
1440 }
1441 }
1442
1443 #[gpui::test]
1444 fn exiting_content_rejects_clicks_focus_and_text_input(cx: &mut TestAppContext) {
1445 use crate::input::{Input, InputState};
1446 use gpui::Focusable as _;
1447
1448 for deferred in [false, true] {
1449 let cx = window(cx, size(px(1000.), px(800.)));
1450 let input = cx.update(|window, cx| cx.new(|cx| InputState::new(window, cx)));
1451 let clicks = Rc::new(Cell::new(0));
1452 let counter = clicks.clone();
1453 let field = input.clone();
1454 cx.update(|_, cx| cx.set_reduce_motion(false));
1455 open(cx, move |dialog, _, _| {
1456 let counter = counter.clone();
1457 dialog
1458 .title("Form")
1459 .child(
1460 div()
1461 .debug_selector(|| "exit-input".into())
1462 .child(Input::new(&field)),
1463 )
1464 .footer(
1465 div().debug_selector(|| "exit-button".into()).child(
1466 Button::new("custom")
1467 .label("Custom action")
1468 .w_full()
1469 .on_click(move |_, _, _| counter.set(counter.get() + 1)),
1470 ),
1471 )
1472 });
1473 cx.background_executor.advance_clock(*ANIMATION_DURATION);
1474 frame(cx);
1475 let button = cx.debug_bounds("exit-button").unwrap().center();
1476 cx.simulate_click(button, gpui::Modifiers::default());
1477 assert_eq!(clicks.get(), 1);
1478 cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1479 cx.simulate_input("before");
1480 assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1481 cx.update(|window, cx| {
1482 if deferred {
1483 Dialog::defer_close_dialog(window, cx);
1484 } else {
1485 window.close_dialog(cx);
1486 }
1487 });
1488 frame(cx);
1489 let button = cx.debug_bounds("exit-button").unwrap().center();
1490 cx.simulate_click(button, gpui::Modifiers::default());
1491 assert_eq!(clicks.get(), 1, "a closing footer button ran its callback");
1492 let field = cx.debug_bounds("exit-input").unwrap().center();
1493 cx.simulate_click(field, gpui::Modifiers::default());
1494 cx.simulate_input("after");
1495 assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1496 for key in ["tab", "shift-tab"] {
1497 cx.simulate_keystrokes(key);
1498 frame(cx);
1499 cx.update(|window, cx| assert!(!input.focus_handle(cx).is_focused(window)));
1500 }
1501 cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1503 frame(cx);
1504 cx.update(|window, cx| assert!(!input.focus_handle(cx).is_focused(window)));
1505 cx.simulate_input("after");
1506 assert_eq!(input.read_with(cx, |input, _| input.value()), "before");
1507 }
1508 }
1509
1510 fn surface_alpha(cx: &mut VisualTestContext) -> f32 {
1512 let bounds = cx.debug_bounds("dialog-0").unwrap();
1513 let scale = cx.update(|window, _| window.scale_factor());
1514 let left = f32::from(bounds.origin.x) * scale;
1515 let top = f32::from(bounds.origin.y) * scale;
1516 let width = f32::from(bounds.size.width) * scale;
1517 let height = f32::from(bounds.size.height) * scale;
1518 cx.update(|window, _| {
1519 window
1520 .painted_quads()
1521 .into_iter()
1522 .filter(|quad| {
1523 (quad.bounds.origin.x.0 - left).abs() < 1.0
1524 && (quad.bounds.origin.y.0 - top).abs() < 1.0
1525 && (quad.bounds.size.width.0 - width).abs() < 1.0
1526 && (quad.bounds.size.height.0 - height).abs() < 1.0
1527 })
1528 .filter_map(|quad| quad.background.as_solid().map(|color| color.a))
1529 .fold(0.0_f32, f32::max)
1530 })
1531 }
1532}