Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/component/src/dialog/alert_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use crate::{
StyledExt as _, WindowExt as _,
button::ButtonVariant,
dialog::{
Dialog, DialogButtonProps, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
Dialog, DialogButtonProps, DialogDescription, DialogEntrance, DialogFooter, DialogHeader,
DialogTitle,
},
h_flex, v_flex,
};
Expand Down Expand Up @@ -270,6 +271,14 @@ impl AlertDialog {
self
}

/// Sets how the alert dialog enters, defaulting to [`DialogEntrance::SlideDown`].
///
/// Shares the surface, backdrop, and reduced-motion policy of [`Dialog::entrance`].
pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
self.base = self.base.entrance(entrance);
self
}

/// Sets the callback for when the alert dialog is closed.
///
/// Called after [`Self::on_action`] or [`Self::on_cancel`] callback.
Expand Down
252 changes: 211 additions & 41 deletions crates/component/src/dialog/dialog.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::entrance::{DialogEntrance, EntranceSurface};
use crate::root::WindowState;
use gpui_base::TestSupportExt as _;
use std::{rc::Rc, sync::LazyLock, time::Duration};
Expand Down Expand Up @@ -210,6 +211,7 @@ pub(crate) struct DialogProps {
overlay_closable: bool,
pub(crate) overlay_visible: bool,
keyboard: bool,
entrance: DialogEntrance,
}

impl Default for DialogProps {
Expand All @@ -223,6 +225,7 @@ impl Default for DialogProps {
overlay_visible: false,
close_button: true,
overlay_closable: true,
entrance: DialogEntrance::default(),
}
}
}
Expand Down Expand Up @@ -324,6 +327,25 @@ pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla {
cx.theme().overlay
}

fn dialog_shadow(delta: f32) -> Vec<BoxShadow> {
vec![
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(20.)),
blur_radius: px(25.),
spread_radius: px(-5.),
inset: false,
},
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(8.)),
blur_radius: px(10.),
spread_radius: px(-6.),
inset: false,
},
]
}

impl Dialog {
/// Create a new dialog.
pub fn new(cx: &mut App) -> Self {
Expand Down Expand Up @@ -488,6 +510,15 @@ impl Dialog {
self
}

/// Sets how the dialog enters, defaulting to [`DialogEntrance::SlideDown`].
///
/// This controls the surface and backdrop together. Reduced motion skips
/// the entrance and immediately shows the final state. Closing remains immediate.
pub fn entrance(mut self, entrance: DialogEntrance) -> Self {
self.props.entrance = entrance;
self
}

pub(crate) fn has_overlay(&self) -> bool {
self.props.overlay
}
Expand Down Expand Up @@ -576,6 +607,12 @@ impl RenderOnce for Dialog {
let margin = cx.theme().spacing_tokens().lg;
let layer_offset = px(layer_ix as f32 * 16.);
let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + layer_offset;
let entrance = self.props.entrance;
let motion = cx.theme().motion_tokens();
let animated = entrance != DialogEntrance::None
&& !cx.reduce_motion()
&& (entrance == DialogEntrance::SlideDown || !motion.duration_normal.is_zero());
let travel = motion.distance_short.to_pixels(window.rem_size());
let width = self
.props
.width
Expand Down Expand Up @@ -604,12 +641,17 @@ impl RenderOnce for Dialog {
// preserving the trajectory this dialog was tuned with before
// `cubic_bezier` solved for x; vaul's (0.32, 0.72, 0., 1.) is far
// more front-loaded under the CSS-correct solver.
let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
1. / 3.,
0.72,
2. / 3.,
1.,
));
let animation = if entrance == DialogEntrance::SlideDown {
Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(
1. / 3.,
0.72,
2. / 3.,
1.,
))
} else {
let easing = motion.easing_enter.clone();
Animation::new(motion.duration_normal).with_easing(move |delta| easing.sample(delta))
};

anchored()
.position(point(window_paddings.left, window_paddings.top))
Expand Down Expand Up @@ -749,45 +791,77 @@ impl RenderOnce for Dialog {
.icon(IconName::Close)
})
}))
.with_animation(
"slide-down-shadow",
animation.clone(),
move |this, delta| {
// This is equivalent to `shadow_xl` with an extra opacity.
let shadow = vec![
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(20.)),
blur_radius: px(25.),
spread_radius: px(-5.),
inset: false,
},
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(8.)),
blur_radius: px(10.),
spread_radius: px(-6.),
inset: false,
.map(|surface| {
if animated {
surface
.with_animation(
"slide-down-shadow",
animation.clone(),
|surface, delta| {
surface.shadow(dialog_shadow(delta))
},
)
.text_selection_scope(selection_scope)
.into_any_element()
} else {
surface
.shadow(dialog_shadow(1.))
.text_selection_scope(selection_scope)
.into_any_element()
}
})
.map(|surface| {
if animated && entrance == DialogEntrance::FadeSlide {
EntranceSurface::new(
surface,
travel,
window_paddings.top + margin,
)
.with_animation(
"dialog-settle",
animation.clone(),
|mut surface, delta| {
surface.progress = delta;
surface
},
];
this.shadow(shadow)
},
)
.text_selection_scope(selection_scope),
)
.into_any_element()
} else {
surface
}
}),
)
.with_animation(
"slide-down",
animation.clone(),
move |this, delta| {
this.position(point(
window_paddings.left + x,
window_paddings.top + y * delta,
))
},
),
.map(|positioner| {
if animated && entrance == DialogEntrance::SlideDown {
positioner
.with_animation(
"slide-down",
animation.clone(),
move |this, delta| {
this.position(point(
window_paddings.left + x,
window_paddings.top + y * delta,
))
},
)
.into_any_element()
} else {
positioner.into_any_element()
}
}),
),
)
.with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
.map(|layer| {
if animated {
layer
.with_animation("fade-in", animation, |layer, delta| {
layer.opacity(delta)
})
.into_any_element()
} else {
layer.into_any_element()
}
}),
)
.into_any_element()
}
Expand Down Expand Up @@ -925,4 +999,100 @@ pub(crate) mod tests {
assert!(second.bottom() <= viewport.height - px(16.), "{second:?}");
assert!(second.size.height < first.size.height);
}

#[gpui::test]
fn test_dialog_entrance_builder(cx: &mut TestAppContext) {
cx.update(crate::init);
cx.update(|cx| {
assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown);
for entrance in [
DialogEntrance::SlideDown,
DialogEntrance::Fade,
DialogEntrance::FadeSlide,
DialogEntrance::None,
] {
assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance);
}
});
}

#[gpui::test]
fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) {
for entrance in [
DialogEntrance::SlideDown,
DialogEntrance::Fade,
DialogEntrance::FadeSlide,
DialogEntrance::None,
] {
let cx = window(cx, size(px(1000.), px(800.)));
open(cx, move |dialog, _, _| {
dialog
.title("Entrance")
.child("body")
.entrance(entrance)
.margin_top(px(300.))
});
assert_eq!(surface(cx, 0).top(), px(300.));
}
}

#[gpui::test]
fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) {
let cx = window(cx, size(px(1000.), px(800.)));
cx.update(|_, cx| cx.set_reduce_motion(false));
open(cx, |dialog, _, _| {
dialog
.title("Immediate")
.child("body")
.entrance(DialogEntrance::None)
.margin_top(px(300.))
});
assert_eq!(surface(cx, 0).top(), px(300.));
cx.simulate_keystrokes("escape");
cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty()));
}

#[gpui::test]
fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) {
for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] {
let cx = window(cx, size(px(1000.), px(800.)));
cx.update(|_, cx| {
cx.set_reduce_motion(false);
crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO);
});
open(cx, move |dialog, _, _| {
dialog
.title("Immediate")
.child("body")
.entrance(entrance)
.margin_top(px(300.))
});
assert_eq!(surface(cx, 0).top(), px(300.));
}
}

#[gpui::test]
fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) {
for entrance in [
DialogEntrance::SlideDown,
DialogEntrance::Fade,
DialogEntrance::FadeSlide,
DialogEntrance::None,
] {
let cx = window(cx, size(px(1000.), px(800.)));
cx.update(|window, cx| {
let alert = crate::dialog::AlertDialog::new(cx)
.title("Alert")
.entrance(entrance);
assert_eq!(alert.build_surface(window, cx).props.entrance, entrance);
window.open_alert_dialog(cx, move |alert, _, _| {
alert.title("Alert").entrance(entrance)
});
});
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(surface(cx, 0).top(), px(80.));
}
}
}
Loading
Loading