use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use gpui::{
px, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement,
StatefulInteractiveElement, StyleRefinement, Styled, Window,
};
use herogpui_core::element_id;
use herogpui_theme::ActiveTheme;
pub const ENTERING_MS: u64 = 150;
pub const TRANSITION_MS: u64 = 100;
pub const TOAST_OPACITY_MS: u64 = 150;
pub const ACCORDION_TRIGGER_HOVER_MS: u64 = 150;
pub const PRESS_MS: u64 = 250;
pub const PROGRESS_BAR_INDETERMINATE_MS: u64 = 1500;
pub const PROGRESS_BAR_FILL_MS: u64 = 300;
pub fn progress_bar_indeterminate_ease() -> impl Fn(f32) -> f32 {
|t| cubic_bezier(0.65, 0.0, 0.35, 1.0, t)
}
pub const PROGRESS_CIRCLE_SPIN_MS: u64 = 1000;
pub const PROGRESS_CIRCLE_FILL_MS: u64 = 300;
pub fn progress_circle_spin_turn(delta: f32) -> f32 {
delta.clamp(0.0, 1.0) * std::f32::consts::TAU
}
fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
let bez = |a: f32, b: f32, u: f32| {
let v = 1.0 - u;
3.0 * v * v * u * a + 3.0 * v * u * u * b + u * u * u
};
let mut lo = 0.0f32;
let mut hi = 1.0f32;
let mut u = t;
for _ in 0..24 {
let x = bez(x1, x2, u);
if x < t {
lo = u;
} else {
hi = u;
}
u = (lo + hi) * 0.5;
}
bez(y1, y2, u)
}
pub fn ease_out() -> impl Fn(f32) -> f32 {
|t| cubic_bezier(0.0, 0.0, 0.2, 1.0, t)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Curve {
Out,
Smooth,
OutQuad,
OutFluid,
OutQuart,
Linear,
}
impl Curve {
pub fn at(self, t: f32) -> f32 {
match self {
Curve::Out => cubic_bezier(0.0, 0.0, 0.2, 1.0, t),
Curve::Smooth => cubic_bezier(0.25, 0.1, 0.25, 1.0, t),
Curve::OutQuad => cubic_bezier(0.25, 0.46, 0.45, 0.94, t),
Curve::OutFluid => cubic_bezier(0.32, 0.72, 0.0, 1.0, t),
Curve::OutQuart => cubic_bezier(0.165, 0.84, 0.44, 1.0, t),
Curve::Linear => t,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Motion {
pub ms: u64,
pub scale: f32,
pub curve: Curve,
}
impl Motion {
pub const PANEL_IN: Motion = Motion {
ms: 250,
scale: 1.05,
curve: Curve::OutQuad,
};
pub const PANEL_OUT: Motion = Motion {
ms: 100,
scale: 0.95,
curve: Curve::OutQuad,
};
pub const BACKDROP_IN: Motion = Motion {
ms: 150,
scale: 1.0,
curve: Curve::Out,
};
pub const BACKDROP_OUT: Motion = Motion {
ms: 100,
scale: 1.0,
curve: Curve::Out,
};
pub const POPOVER_IN: Motion = Motion {
ms: 150,
scale: 0.90,
curve: Curve::Smooth,
};
pub const LIST_IN: Motion = Motion {
ms: 150,
scale: 0.95,
curve: Curve::Smooth,
};
pub const LIST_OUT: Motion = Motion {
ms: 100,
scale: 0.95,
curve: Curve::Smooth,
};
pub const DISCLOSURE: Motion = Motion {
ms: 200,
scale: 1.0,
curve: Curve::OutQuad,
};
pub const FIELD_ERROR_HEIGHT: Motion = Motion {
ms: 350,
scale: 1.0,
curve: Curve::Smooth,
};
pub const FIELD_ERROR_OPACITY: Motion = Motion {
ms: 150,
scale: 1.0,
curve: Curve::Out,
};
pub const DRAWER_IN: Motion = Motion {
ms: 250,
scale: 1.0,
curve: Curve::OutFluid,
};
pub const DRAWER_OUT: Motion = Motion {
ms: 200,
scale: 1.0,
curve: Curve::OutFluid,
};
pub const DRAWER_BACKDROP_IN: Motion = Motion {
ms: 250,
scale: 1.0,
curve: Curve::OutFluid,
};
pub const DRAWER_BACKDROP_OUT: Motion = Motion {
ms: 200,
scale: 1.0,
curve: Curve::OutFluid,
};
pub const FLUID_IN: Motion = Motion {
ms: 250,
scale: 0.95,
curve: Curve::OutFluid,
};
pub const FLUID_OUT: Motion = Motion {
ms: 100,
scale: 0.95,
curve: Curve::OutQuad,
};
pub const TOAST_IN: Motion = Motion {
ms: 350,
scale: 1.0,
curve: Curve::OutFluid,
};
pub const TOAST_OUT: Motion = Motion {
ms: 350,
scale: 1.0,
curve: Curve::OutFluid,
};
pub const TOAST_STACK_OUT: Motion = Motion {
ms: 200,
scale: 0.96,
curve: Curve::OutFluid,
};
}
pub fn ease_smooth() -> impl Fn(f32) -> f32 {
|t| cubic_bezier(0.25, 0.1, 0.25, 1.0, t)
}
pub(crate) fn tailwind_default_ease() -> impl Fn(f32) -> f32 {
|t| cubic_bezier(0.4, 0.0, 0.2, 1.0, t)
}
#[allow(dead_code)]
pub(crate) const INDICATOR_ROTATION_MS: u64 = 250;
pub(crate) const YEAR_PICKER_INDICATOR_MS: u64 = 150;
pub(crate) const YEAR_PICKER_INDICATOR_ANGLE: f32 = std::f32::consts::FRAC_PI_2;
pub(crate) fn rotating_indicator(
id: &ElementId,
expanded: bool,
svg: gpui::Svg,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
rotating_indicator_with_duration(id, expanded, svg, Motion::DISCLOSURE.ms, window, cx)
}
pub(crate) fn rotating_indicator_with_duration(
id: &ElementId,
expanded: bool,
svg: gpui::Svg,
duration_ms: u64,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
rotating_indicator_with_angle(
id,
expanded,
svg,
duration_ms,
std::f32::consts::PI,
window,
cx,
)
}
pub(crate) fn rotating_indicator_with_angle(
id: &ElementId,
expanded: bool,
svg: gpui::Svg,
duration_ms: u64,
angle: f32,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
rotating_indicator_with_angle_easing(
id,
expanded,
svg,
duration_ms,
angle,
IndicatorEasing::TailwindDefault,
window,
cx,
)
}
pub(crate) fn rotating_indicator_with_angle_ease_out(
id: &ElementId,
expanded: bool,
svg: gpui::Svg,
duration_ms: u64,
angle: f32,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
rotating_indicator_with_angle_easing(
id,
expanded,
svg,
duration_ms,
angle,
IndicatorEasing::EaseOut,
window,
cx,
)
}
#[derive(Clone, Copy)]
enum IndicatorEasing {
TailwindDefault,
EaseOut,
}
#[allow(clippy::too_many_arguments)] fn rotating_indicator_with_angle_easing(
id: &ElementId,
expanded: bool,
svg: gpui::Svg,
duration_ms: u64,
angle: f32,
easing: IndicatorEasing,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let reduce_motion = ActiveTheme::reduce_motion(cx);
let target = if expanded { 1.0 } else { 0.0 };
let mut rotation = Tween::keyed(id, "indicator-rotation", target, window, cx);
rotation.snap_if_reduced(reduce_motion);
if !rotation.animates(reduce_motion) {
rotation.settle();
return svg
.with_transformation(gpui::Transformation::rotate(gpui::radians(
rotation.target() * angle,
)))
.into_any_element();
}
let from = rotation.from();
let to = rotation.target();
let value = rotation.value();
let animation = gpui::Animation::new(Duration::from_millis(duration_ms));
let animation = match easing {
IndicatorEasing::TailwindDefault => animation.with_easing(tailwind_default_ease()),
IndicatorEasing::EaseOut => animation.with_easing(ease_out()),
};
svg.with_animation(
element_id::indexed(id, "indicator-rotation", rotation.generation()),
animation,
move |svg, delta| {
let progress = from + (to - from) * delta;
value.set(progress);
svg.with_transformation(gpui::Transformation::rotate(gpui::radians(
progress * angle,
)))
},
)
.into_any_element()
}
#[derive(Clone)]
pub(crate) struct Tween<T: Copy + PartialEq + 'static> {
target: T,
generation: usize,
from: T,
value: Rc<Cell<T>>,
}
impl<T: Copy + PartialEq + 'static> Tween<T> {
fn settled(value: T) -> Self {
Self {
target: value,
generation: 0,
from: value,
value: Rc::new(Cell::new(value)),
}
}
pub(crate) fn keyed(
id: &ElementId,
tag: &'static str,
target: T,
window: &mut Window,
cx: &mut App,
) -> Self {
Self::keyed_with_initial(id, tag, target, None, window, cx)
}
pub(crate) fn keyed_with_initial(
id: &ElementId,
tag: &'static str,
target: T,
initial_from: Option<T>,
window: &mut Window,
cx: &mut App,
) -> Self {
let state =
window.use_keyed_state(element_id::scoped(id, tag), cx, |_, _| match initial_from {
Some(from) if from != target => Self {
target,
generation: 1,
from,
value: Rc::new(Cell::new(from)),
},
_ => Self::settled(target),
});
let mut current = state.read(cx).clone();
if current.target != target {
current.target = target;
current.generation = current.generation.wrapping_add(1);
current.from = current.value.get();
state.update(cx, |stored, _| *stored = current.clone());
}
current
}
pub(crate) fn snap_if_reduced(&mut self, reduce_motion: bool) {
if reduce_motion && self.value.get() != self.target {
self.from = self.target;
self.value.set(self.target);
}
}
pub(crate) fn animates(&self, reduce_motion: bool) -> bool {
self.generation != 0 && !reduce_motion && self.value.get() != self.target
}
pub(crate) fn settle(&self) {
self.value.set(self.target);
}
pub(crate) fn target(&self) -> T {
self.target
}
pub(crate) fn from(&self) -> T {
self.from
}
pub(crate) fn generation(&self) -> usize {
self.generation
}
pub(crate) fn value(&self) -> Rc<Cell<T>> {
Rc::clone(&self.value)
}
}
pub const PRESSED_SCALE: f32 = 0.97;
pub const PRESSED_SCALE_SUBTLE: f32 = 0.98;
pub(crate) const LIST_ITEM_PRESS: PressTiming = PressTiming {
transform_ms: 250,
transform: Curve::OutQuart,
background: None,
};
pub const PRESSED_SCALE_FIRM: f32 = 0.96;
pub const PRESSED_SCALE_DEEP: f32 = 0.95;
pub const PRESSED_SCALE_RANGE: f32 = 0.9;
pub fn pressed_inset(height: gpui::Pixels) -> gpui::Pixels {
inset_for(height, PRESSED_SCALE)
}
fn inset_for(height: gpui::Pixels, scale: f32) -> gpui::Pixels {
px(f32::from(height) * (1.0 - scale) / 2.0)
}
#[cfg(test)]
fn shrink(value: gpui::Pixels, by: gpui::Pixels) -> gpui::Pixels {
px((f32::from(value) - f32::from(by)).max(0.0))
}
fn scaled_by(value: gpui::Pixels, scale: f32) -> gpui::Pixels {
px(f32::from(value) * scale)
}
pub(crate) fn pressed_corners(
corners: &gpui::CornersRefinement<gpui::AbsoluteLength>,
fallback: gpui::Pixels,
scale: f32,
) -> gpui::Corners<Option<gpui::Pixels>> {
let scale_corner = |corner| {
let radius = match corner {
Some(gpui::AbsoluteLength::Pixels(radius)) => radius,
_ => fallback,
};
Some(scaled_by(radius, scale))
};
gpui::Corners {
top_left: scale_corner(corners.top_left),
top_right: scale_corner(corners.top_right),
bottom_right: scale_corner(corners.bottom_right),
bottom_left: scale_corner(corners.bottom_left),
}
}
fn resting_slot_corners(
corners: &gpui::CornersRefinement<gpui::AbsoluteLength>,
fallback: gpui::Pixels,
) -> gpui::Corners<Option<gpui::Pixels>> {
let any_specified = corners.top_left.is_some()
|| corners.top_right.is_some()
|| corners.bottom_right.is_some()
|| corners.bottom_left.is_some();
let resolve = |corner| match corner {
Some(gpui::AbsoluteLength::Pixels(radius)) => Some(radius),
Some(_) => Some(fallback),
None if any_specified => None,
None => Some(fallback),
};
gpui::Corners {
top_left: resolve(corners.top_left),
top_right: resolve(corners.top_right),
bottom_right: resolve(corners.bottom_right),
bottom_left: resolve(corners.bottom_left),
}
}
#[derive(Clone, Copy, Debug)]
pub struct PressBox {
pub height: gpui::Pixels,
pub padding_x: Option<gpui::Pixels>,
pub width: Option<gpui::Pixels>,
pub min_width: Option<gpui::Pixels>,
pub text_size: gpui::Pixels,
pub line_height: gpui::Pixels,
pub gap: gpui::Pixels,
pub radius: gpui::Pixels,
pub scale: f32,
pub shrink_x: bool,
}
pub fn pressed(el: gpui::Stateful<gpui::Div>, b: PressBox, cx: &App) -> gpui::Stateful<gpui::Div> {
pressed_with_optional_background(el, b, None, cx)
}
pub fn pressed_with_background(
el: gpui::Stateful<gpui::Div>,
b: PressBox,
background: gpui::Hsla,
cx: &App,
) -> gpui::Stateful<gpui::Div> {
pressed_with_optional_background(el, b, Some(background), cx)
}
#[derive(Default)]
struct PressSlotSizes(RefCell<HashMap<ElementId, f32>>);
impl gpui::Global for PressSlotSizes {}
fn press_slot_width(cx: &App, id: &ElementId) -> Option<f32> {
let slots = cx.try_global::<PressSlotSizes>()?;
slots.0.borrow().get(id).copied()
}
fn press_slot_recorder(id: ElementId) -> AnyElement {
gpui::canvas(
|_, _, _| {},
move |bounds, _, _, cx| {
let width = f32::from(bounds.size.width);
let slots = cx.default_global::<PressSlotSizes>();
if slots.0.borrow().get(&id).copied() != Some(width) {
slots.0.borrow_mut().insert(id, width);
}
},
)
.absolute()
.inset_0()
.into_any_element()
}
fn pressed_with_optional_background(
mut el: gpui::Stateful<gpui::Div>,
b: PressBox,
background: Option<gpui::Hsla>,
cx: &App,
) -> gpui::Stateful<gpui::Div> {
if ActiveTheme::reduce_motion(cx) {
return match background {
Some(background) => el.active(move |style| style.bg(background)),
None => el,
};
}
let inset = gpui::DefiniteLength::Fraction((1.0 - b.scale) / 2.0);
let pressed_min_height = scaled_by(b.height, b.scale);
let pressed_radius = scaled_by(b.radius, b.scale);
let corners = pressed_corners(&el.style().corner_radii, b.radius, b.scale);
let el = el.on_mouse_down(gpui::MouseButton::Left, |_, _, _| {});
let mut el = el.active(move |s: StyleRefinement| {
let s = match background {
Some(background) => s.bg(background),
None => s,
};
crate::util::round_sx_corners(
s.absolute()
.left(inset)
.right(inset)
.top(inset)
.bottom(inset)
.w(gpui::Length::Auto)
.h(gpui::Length::Auto)
.min_h(pressed_min_height)
.rounded(pressed_radius),
&corners,
)
});
let Some(id) = el.interactivity().element_id.clone() else {
return el;
};
let slot_corners = resting_slot_corners(&el.style().corner_radii, b.radius);
press_slot(el, id, b, &slot_corners, cx)
}
fn press_slot(
el: impl IntoElement,
id: ElementId,
b: PressBox,
slot_corners: &gpui::Corners<Option<gpui::Pixels>>,
cx: &App,
) -> gpui::Stateful<gpui::Div> {
let mut slot = gpui::div()
.id(element_id::scoped(&id, "press-slot"))
.relative()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.min_h(b.height);
slot = crate::util::round_sx_corners(slot, slot_corners);
slot = match b.width {
Some(width) => slot.w(width),
None if !b.shrink_x => slot.w_full(),
None => {
if let Some(width) = press_slot_width(cx, &id) {
slot = slot.w(px(width));
}
slot.child(press_slot_recorder(id))
}
};
slot.child(el)
}
fn resting_corner_radii(
el: &mut gpui::Stateful<gpui::Div>,
fallback: gpui::Pixels,
) -> gpui::Corners<Option<gpui::Pixels>> {
pressed_corners(&el.style().corner_radii, fallback, 1.0)
}
fn scale_corners(
corners: &gpui::Corners<Option<gpui::Pixels>>,
s: f32,
) -> gpui::Corners<Option<gpui::Pixels>> {
let scale = |radius: Option<gpui::Pixels>| radius.map(|radius| scaled_by(radius, s));
gpui::Corners {
top_left: scale(corners.top_left),
top_right: scale(corners.top_right),
bottom_right: scale(corners.bottom_right),
bottom_left: scale(corners.bottom_left),
}
}
fn skin_at_scale(
skin: gpui::Stateful<gpui::Div>,
s: f32,
b: &PressBox,
resting: &gpui::Corners<Option<gpui::Pixels>>,
) -> gpui::Stateful<gpui::Div> {
let inset = gpui::DefiniteLength::Fraction((1.0 - s) / 2.0);
crate::util::round_sx_corners(
skin.absolute()
.left(inset)
.right(inset)
.top(inset)
.bottom(inset)
.w(gpui::Length::Auto)
.h(gpui::Length::Auto)
.min_h(scaled_by(b.height, s))
.rounded(scaled_by(b.radius, s)),
&scale_corners(resting, s),
)
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct PressTiming {
pub transform_ms: u64,
pub transform: Curve,
pub background: Option<(u64, Curve)>,
}
pub(crate) const BUTTON_PRESS: PressTiming = PressTiming {
transform_ms: PRESS_MS,
transform: Curve::Smooth,
background: Some((100, Curve::Out)),
};
pub(crate) const CLOSE_BUTTON_PRESS: PressTiming = PressTiming {
transform_ms: 250,
transform: Curve::OutQuart,
background: None,
};
pub(crate) fn pressed_with_background_ramp(
mut el: gpui::Stateful<gpui::Div>,
b: PressBox,
endpoints: Option<(gpui::Hsla, gpui::Hsla)>,
timing: PressTiming,
interaction: Option<&crate::util::Interaction>,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
if !b.shrink_x {
el = el.w_full();
}
let slot_corners = resting_slot_corners(&el.style().corner_radii, b.radius);
let Some(id) = el.interactivity().element_id.clone() else {
return el;
};
let (tracked, pressed) = match interaction {
Some(slot) => (None, slot.read(cx).1),
None => {
let tracked =
crate::util::interaction(element_id::scoped(&id, "press-track"), window, cx);
let pressed = tracked.read(cx).1;
(Some(tracked), pressed)
}
};
let (resting, pressed_bg) = endpoints.unwrap_or_default();
let reduce = ActiveTheme::reduce_motion(cx);
let mut scale_tween = Tween::keyed(
&id,
"press-transform",
if pressed { b.scale } else { 1.0 },
window,
cx,
);
let mut color_tween = endpoints.map(|_| {
Tween::keyed(
&id,
"press-bg",
if pressed { pressed_bg } else { resting },
window,
cx,
)
});
scale_tween.snap_if_reduced(reduce);
if let Some(tween) = &mut color_tween {
tween.snap_if_reduced(reduce);
}
let resting_corners = resting_corner_radii(&mut el, b.radius);
let animates = scale_tween.animates(reduce)
|| color_tween
.as_ref()
.is_some_and(|tween| tween.animates(reduce));
let skin = if animates {
let transform_ms = timing.transform_ms;
let transform = timing.transform;
let scale_from = scale_tween.from();
let scale_to = scale_tween.target();
let scale_value = scale_tween.value();
let transform_id = element_id::indexed(&id, "press-transform", scale_tween.generation());
let geometry = move |delta: f32| {
let s = if delta >= 1.0 {
scale_to
} else {
scale_from + (scale_to - scale_from) * delta
};
scale_value.set(s);
s
};
match color_tween {
Some(tween) => {
let (color_from, color_to) = (tween.from(), tween.target());
let color_value = tween.value();
let (bg_ms, bg_curve) = timing.background.unwrap_or((100, Curve::Out));
let animated = el
.with_animation(
element_id::indexed(&id, "press-bg", tween.generation()),
gpui::Animation::new(Duration::from_millis(bg_ms))
.with_easing(move |t| bg_curve.at(t)),
move |skin, delta| {
let color = if delta >= 1.0 {
color_to
} else {
herogpui_core::mix_oklab(color_from, color_to, delta)
};
color_value.set(color);
skin.bg(color)
},
)
.with_animation(
transform_id,
transform_animation(transform_ms, transform),
move |el, delta| {
let s = geometry(delta);
el.map_element(|skin| skin_at_scale(skin, s, &b, &resting_corners))
},
);
animated.into_any_element()
}
None => {
let animated = el.with_animation(
transform_id,
transform_animation(transform_ms, transform),
move |skin, delta| {
let s = geometry(delta);
skin_at_scale(skin, s, &b, &resting_corners)
},
);
animated.into_any_element()
}
}
} else {
let s = scale_tween.value().get();
let mut skin = el;
#[allow(clippy::float_cmp)] if s != 1.0 {
skin = skin_at_scale(skin, s, &b, &resting_corners);
if pressed && endpoints.is_some() {
skin = skin.bg(pressed_bg);
}
}
skin.into_any_element()
};
let slot = press_slot(skin, id, b, &slot_corners, cx);
match interaction {
Some(_) => slot,
None => crate::util::track_interaction(
slot,
tracked
.as_ref()
.expect("a press ramp without an interaction slot creates one"),
),
}
}
fn transform_animation(ms: u64, curve: Curve) -> gpui::Animation {
gpui::Animation::new(Duration::from_millis(ms)).with_easing(move |t| curve.at(t))
}
pub const EXITING_MS: u64 = 100;
#[derive(Clone, Copy, Debug, Default)]
pub struct ZoomBox {
pub width: Option<gpui::Pixels>,
pub height: Option<gpui::Pixels>,
pub padding_x: Option<gpui::Pixels>,
pub padding_y: Option<gpui::Pixels>,
pub padding_top: Option<gpui::Pixels>,
pub padding_bottom: Option<gpui::Pixels>,
pub gap: Option<gpui::Pixels>,
pub text_size: Option<gpui::Pixels>,
pub line_height: Option<gpui::Pixels>,
pub radius: Option<gpui::Pixels>,
pub slide_x: Option<gpui::Pixels>,
pub slide_y: Option<gpui::Pixels>,
}
impl ZoomBox {
pub fn panel(padding_y: gpui::Pixels, radius: gpui::Pixels) -> Self {
Self {
padding_y: Some(padding_y),
radius: Some(radius),
..Default::default()
}
}
pub fn padding_x(mut self, padding_x: gpui::Pixels) -> Self {
self.padding_x = Some(padding_x);
self
}
pub fn sized(mut self, width: gpui::Pixels) -> Self {
self.width = Some(width);
self
}
pub fn text(mut self, text_size: gpui::Pixels) -> Self {
self.text_size = Some(text_size);
self
}
}
fn lerp(value: gpui::Pixels, factor: f32) -> gpui::Pixels {
px(f32::from(value) * factor)
}
pub fn entering_zoom<E>(
el: E,
id: impl Into<ElementId>,
b: ZoomBox,
m: Motion,
cx: &App,
) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if ActiveTheme::reduce_motion(cx) {
return el.into_any_element();
}
el.with_animation(
id.into(),
gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
move |el, delta| {
let f = m.scale + (1.0 - m.scale) * delta;
let mut el = el.opacity(delta);
if let Some(w) = b.width {
el = el.w(lerp(w, f));
}
if let Some(h) = b.height {
el = el.h(lerp(h, f));
}
if let Some(p) = b.padding_x {
el = el.px(lerp(p, f));
}
if let Some(p) = b.padding_y {
el = el.py(lerp(p, f));
}
if let Some(p) = b.padding_top {
el = el.pt(lerp(p, f));
}
if let Some(p) = b.padding_bottom {
el = el.pb(lerp(p, f));
}
if let Some(g) = b.gap {
el = el.gap(lerp(g, f));
}
if let Some(t) = b.text_size {
el = el.text_size(lerp(t, f));
}
if let Some(l) = b.line_height {
el = el.line_height(lerp(l, f));
}
if let Some(r) = b.radius {
el = el.rounded(lerp(r, f));
}
if b.slide_x.is_some() || b.slide_y.is_some() {
el = el.relative();
}
if let Some(x) = b.slide_x {
el = el.left(lerp(x, 1.0 - delta));
}
if let Some(y) = b.slide_y {
el = el.top(lerp(y, 1.0 - delta));
}
el
},
)
.into_any_element()
}
pub fn exiting<E>(el: E, id: impl Into<ElementId>, b: ZoomBox, m: Motion, cx: &App) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if ActiveTheme::reduce_motion(cx) {
return el.into_any_element();
}
el.with_animation(
id.into(),
gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
move |el, delta| {
let f = 1.0 - (1.0 - m.scale) * delta;
let mut el = el.opacity(1.0 - delta);
if let Some(w) = b.width {
el = el.w(lerp(w, f));
}
if let Some(h) = b.height {
el = el.h(lerp(h, f));
}
if let Some(p) = b.padding_x {
el = el.px(lerp(p, f));
}
if let Some(p) = b.padding_y {
el = el.py(lerp(p, f));
}
if let Some(p) = b.padding_top {
el = el.pt(lerp(p, f));
}
if let Some(p) = b.padding_bottom {
el = el.pb(lerp(p, f));
}
if let Some(g) = b.gap {
el = el.gap(lerp(g, f));
}
if let Some(t) = b.text_size {
el = el.text_size(lerp(t, f));
}
if let Some(l) = b.line_height {
el = el.line_height(lerp(l, f));
}
if let Some(r) = b.radius {
el = el.rounded(lerp(r, f));
}
el
},
)
.into_any_element()
}
#[allow(clippy::too_many_arguments)] pub fn hover_fade(
el: gpui::Stateful<gpui::Div>,
id: impl Into<ElementId>,
colors: (gpui::Hsla, gpui::Hsla),
interaction: Option<&crate::util::Interaction>,
hover_border: Option<gpui::Hsla>,
round_corners: impl Fn(gpui::Div) -> gpui::Div,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
hover_fade_with_duration_and_easing(
el,
id,
colors,
interaction,
hover_border,
round_corners,
None,
HoverFadeEasing::EaseOut,
window,
cx,
)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn hover_fade_with_duration(
el: gpui::Stateful<gpui::Div>,
id: impl Into<ElementId>,
colors: (gpui::Hsla, gpui::Hsla),
interaction: Option<&crate::util::Interaction>,
hover_border: Option<gpui::Hsla>,
round_corners: impl Fn(gpui::Div) -> gpui::Div,
duration_override_ms: Option<u64>,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
hover_fade_with_duration_and_easing(
el,
id,
colors,
interaction,
hover_border,
round_corners,
duration_override_ms,
HoverFadeEasing::EaseOut,
window,
cx,
)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn hover_fade_with_duration_and_easing(
el: gpui::Stateful<gpui::Div>,
id: impl Into<ElementId>,
colors: (gpui::Hsla, gpui::Hsla),
interaction: Option<&crate::util::Interaction>,
hover_border: Option<gpui::Hsla>,
round_corners: impl Fn(gpui::Div) -> gpui::Div,
duration_override_ms: Option<u64>,
easing: HoverFadeEasing,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
hover_fade_with_duration_and_easing_suppressed(
el,
id,
colors,
interaction,
hover_border,
false,
round_corners,
duration_override_ms,
easing,
window,
cx,
)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn hover_fade_with_duration_and_easing_suppressed(
el: gpui::Stateful<gpui::Div>,
id: impl Into<ElementId>,
colors: (gpui::Hsla, gpui::Hsla),
interaction: Option<&crate::util::Interaction>,
hover_border: Option<gpui::Hsla>,
suppressed: bool,
round_corners: impl Fn(gpui::Div) -> gpui::Div,
duration_override_ms: Option<u64>,
easing: HoverFadeEasing,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
let (idle, hovered) = colors;
let id = id.into();
let duration = match duration_override_ms {
None => hover_fade_duration(cx),
Some(ms) => hover_fade_duration_with_override(cx, Some(ms)),
};
let state = window.use_keyed_state(id.clone(), cx, |_, _| HoverFade::default());
let mut current = *state.read(cx);
let mut el = el;
if duration.is_some() {
el = el.relative();
}
el = el.bg(if current.hovered { hovered } else { idle });
el = match interaction {
Some(slot) => {
debug_assert!(
hover_border.is_none(),
"the interaction slot's owner keeps its own gpui hover style; \
`hover_border` is only read by the listener-owned branch"
);
let hovered_now = slot.read(cx).0 && !suppressed;
if hovered_now != current.hovered {
current.hovered = hovered_now;
current.generation = current.generation.wrapping_add(1);
state.update(cx, |s, _| *s = current);
}
el
}
None => {
let el = el.hover(move |style| match hover_border {
Some(color) => style.border_color(color),
None => style,
});
let held = state.clone();
el.on_hover(move |over: &bool, _, cx| {
let over = *over && !suppressed;
held.update(cx, |s, _| {
if s.hovered != over {
s.hovered = over;
s.generation = s.generation.wrapping_add(1);
}
});
})
}
};
let Some(duration) = duration else {
return el;
};
if current.generation == 0 {
return el;
}
let (from, to) = if current.hovered {
(idle, hovered)
} else {
(hovered, idle)
};
el.child(
round_corners(gpui::div().absolute().inset_0()).with_animation(
element_id::indexed(&id, "fade", current.generation),
gpui::Animation::new(duration).with_easing(move |t| easing.at(t)),
move |fill, delta| fill.bg(herogpui_core::mix_oklab(from, to, delta)),
),
)
}
#[derive(Clone, Copy)]
pub(crate) enum HoverFadeEasing {
EaseOut,
EaseSmooth,
}
impl HoverFadeEasing {
fn at(self, t: f32) -> f32 {
match self {
Self::EaseOut => ease_out()(t),
Self::EaseSmooth => ease_smooth()(t),
}
}
}
fn hover_fade_duration(cx: &App) -> Option<Duration> {
hover_fade_duration_with_override(cx, None)
}
fn hover_fade_duration_with_override(
cx: &App,
duration_override_ms: Option<u64>,
) -> Option<Duration> {
if ActiveTheme::reduce_motion(cx) {
return None;
}
let configured = cx.layout().hover_fade_ms;
let ms = if configured == 0 {
0
} else {
duration_override_ms.unwrap_or(configured)
};
(ms > 0).then(|| Duration::from_millis(ms))
}
#[derive(Clone, Copy, Debug, Default)]
struct HoverFade {
hovered: bool,
generation: usize,
}
pub(crate) const FIELD_CHROME_COLOR_MS: u64 = 150;
pub(crate) const FIELD_CHROME_COLOR_CURVE: Curve = Curve::Smooth;
pub(crate) const FIELD_CHROME_SHADOW_MS: u64 = 150;
pub(crate) const FIELD_CHROME_SHADOW_CURVE: Curve = Curve::Out;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct FieldRing {
pub ring: (gpui::Hsla, f32),
pub gap: (gpui::Hsla, f32),
}
impl FieldRing {
pub const NONE: Self = Self {
ring: (gpui::hsla(0., 0., 0., 0.), 0.0),
gap: (gpui::hsla(0., 0., 0., 0.), 0.0),
};
pub(crate) fn solid(color: gpui::Hsla, spread: f32) -> Self {
Self {
ring: (color, spread),
gap: Self::NONE.ring,
}
}
fn faded(self) -> Self {
Self {
ring: (herogpui_core::with_alpha(self.ring.0, 0.0), 0.0),
gap: (herogpui_core::with_alpha(self.gap.0, 0.0), 0.0),
}
}
fn shadows(self) -> Vec<gpui::BoxShadow> {
let mut shadows = vec![ring_shadow(self.ring)];
if self.gap.1 > 0.0 {
shadows.push(ring_shadow(self.gap));
}
shadows
}
fn mix(self, to: Self, delta: f32) -> Self {
let lerp = |a: f32, b: f32| a + (b - a) * delta;
Self {
ring: (
herogpui_core::mix_oklab(self.ring.0, to.ring.0, delta),
lerp(self.ring.1, to.ring.1),
),
gap: (
herogpui_core::mix_oklab(self.gap.0, to.gap.0, delta),
lerp(self.gap.1, to.gap.1),
),
}
}
}
fn ring_shadow((color, spread): (gpui::Hsla, f32)) -> gpui::BoxShadow {
gpui::BoxShadow {
color,
offset: gpui::point(px(0.), px(0.)),
blur_radius: px(1.),
spread_radius: px(spread),
inset: false,
}
}
pub(crate) fn focus_ring_endpoint(cx: &App) -> FieldRing {
let colors = cx.colors();
let gap = cx.layout().ring_offset_width;
let mut ring = FieldRing::solid(colors.focus, 2.0 + f32::from(gap));
if gap > px(0.) {
ring.gap = (colors.background, f32::from(gap));
}
ring
}
pub(crate) fn danger_ring_endpoint(cx: &App) -> FieldRing {
FieldRing::solid(cx.colors().danger.color, 2.0)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct FieldChrome {
pub bg: gpui::Hsla,
pub border: gpui::Hsla,
pub border_width: gpui::Pixels,
pub ring: Option<FieldRing>,
}
#[allow(clippy::too_many_arguments)] pub(crate) fn field_chrome_ramp<E>(
mut el: E,
id: &ElementId,
idle: FieldChrome,
hovered: Option<FieldChrome>,
base_shadows: Vec<gpui::BoxShadow>,
radius: gpui::Pixels,
ring_escapes_clip: bool,
window: &mut Window,
cx: &mut App,
) -> (E, bool)
where
E: InteractiveElement + Styled + ParentElement,
{
let slot = window.use_keyed_state(element_id::scoped(id, "chrome-hover"), cx, |_, _| false);
let is_hovered = *slot.read(cx) && hovered.is_some();
el = el.hover(|style| style);
el.interactivity().on_hover({
move |over: &bool, _, cx| {
slot.update(cx, |hovered, _| {
if *hovered != *over {
*hovered = *over;
}
});
}
});
let (target, other_ring) = if is_hovered {
(hovered.unwrap_or(idle), idle.ring)
} else {
(idle, hovered.and_then(|hovered| hovered.ring))
};
let ring_tracked = idle.ring.is_some() || target.ring.is_some();
let reduce = ActiveTheme::reduce_motion(cx);
let mut bg = Tween::keyed(id, "chrome-bg", target.bg, window, cx);
let mut border = Tween::keyed(id, "chrome-border", target.border, window, cx);
let ring_target = target
.ring
.or_else(|| other_ring.map(|ring| ring.faded()))
.unwrap_or(FieldRing::NONE);
let mut ring = Tween::keyed(id, "chrome-ring", ring_target, window, cx);
bg.snap_if_reduced(reduce);
border.snap_if_reduced(reduce);
ring.snap_if_reduced(reduce);
let pristine = bg.generation() == 0 && border.generation() == 0 && ring.generation() == 0;
if pristine {
return (el, false);
}
el = el.relative().border(px(0.));
if ring_tracked {
el = if base_shadows.is_empty() {
el
} else {
el.shadow(base_shadows)
};
}
let (bg_from, bg_to) = (bg.from(), bg.target());
let bg_value = bg.value();
let (border_from, border_to) = (border.from(), border.target());
let border_value = border.value();
let border_width = idle.border_width.max(target.border_width);
let (ring_from, ring_to) = (ring.from(), ring.target());
let ring_value = ring.value();
let fill = if bg.animates(reduce) {
bg_value.set(bg_from);
gpui::div()
.absolute()
.inset_0()
.rounded(radius)
.with_animation(
element_id::indexed(id, "chrome-bg", bg.generation()),
gpui::Animation::new(Duration::from_millis(FIELD_CHROME_COLOR_MS))
.with_easing(|t| FIELD_CHROME_COLOR_CURVE.at(t)),
move |fill, delta| {
let next = if delta >= 1.0 {
bg_to
} else {
herogpui_core::mix_oklab(bg_from, bg_to, delta)
};
bg_value.set(next);
fill.bg(next)
},
)
.into_any_element()
} else {
bg.settle();
gpui::div()
.absolute()
.inset_0()
.rounded(radius)
.bg(bg_to)
.into_any_element()
};
let border_layer = if border.animates(reduce) {
border_value.set(border_from);
gpui::div()
.absolute()
.inset_0()
.rounded(radius)
.with_animation(
element_id::indexed(id, "chrome-border", border.generation()),
gpui::Animation::new(Duration::from_millis(FIELD_CHROME_COLOR_MS))
.with_easing(|t| FIELD_CHROME_COLOR_CURVE.at(t)),
move |layer, delta| {
let next = if delta >= 1.0 {
border_to
} else {
herogpui_core::mix_oklab(border_from, border_to, delta)
};
border_value.set(next);
layer.border(border_width).border_color(next)
},
)
.into_any_element()
} else {
border.settle();
gpui::div()
.absolute()
.inset_0()
.rounded(radius)
.border(border_width)
.border_color(border_to)
.into_any_element()
};
let ring_layer: Option<AnyElement> = if ring.generation() == 0 {
None
} else if ring.animates(reduce) {
ring_value.set(ring_from);
Some(
gpui::div()
.absolute()
.inset_0()
.rounded(radius)
.with_animation(
element_id::indexed(id, "chrome-ring", ring.generation()),
gpui::Animation::new(Duration::from_millis(FIELD_CHROME_SHADOW_MS))
.with_easing(|t| FIELD_CHROME_SHADOW_CURVE.at(t)),
move |layer, delta| {
let next = if delta >= 1.0 {
ring_to
} else {
ring_from.mix(ring_to, delta)
};
ring_value.set(next);
layer.shadow(next.shadows())
},
)
.into_any_element(),
)
} else {
ring.settle();
Some(
gpui::div()
.absolute()
.inset_0()
.rounded(radius)
.shadow(ring_to.shadows())
.into_any_element(),
)
};
let ring_layer: Option<AnyElement> = match ring_layer {
Some(layer) if ring_escapes_clip => Some(crate::util::floating(layer).into_any_element()),
some => some,
};
el = el.child(fill).child(border_layer);
let ring_painted = ring_layer.is_some();
if let Some(ring_layer) = ring_layer {
el = el.child(ring_layer);
}
(el, ring_painted)
}
pub fn caret_blink<E>(el: E, id: impl Into<ElementId>, cx: &App) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if ActiveTheme::reduce_motion(cx) {
return el.into_any_element();
}
el.with_animation(
id.into(),
gpui::Animation::new(Duration::from_millis(1000)).repeat(),
|el, delta| el.opacity(caret_opacity(delta)),
)
.into_any_element()
}
fn caret_opacity(delta: f32) -> f32 {
match delta {
d if d < 0.20 => 1.0 - (d / 0.20),
d if d < 0.50 => 0.0,
d if d < 0.70 => (d - 0.50) / 0.20,
_ => 1.0,
}
}
pub fn entering<E>(el: E, id: impl Into<ElementId>, m: Motion, cx: &App) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if ActiveTheme::reduce_motion(cx) {
return el.into_any_element();
}
el.with_animation(
id.into(),
gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
Styled::opacity,
)
.into_any_element()
}
pub(crate) fn collapsible_panel(
id: &ElementId,
is_open: bool,
panel: gpui::Stateful<gpui::Div>,
body: AnyElement,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
collapsible_panel_with_timings(
id,
is_open,
panel,
body,
Motion::DISCLOSURE,
Motion::DISCLOSURE,
window,
cx,
)
}
pub(crate) fn field_error_panel(
id: &ElementId,
message: Option<gpui::SharedString>,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let retained =
window.use_keyed_state(element_id::scoped(id, "field-error-message"), cx, |_, _| {
None::<gpui::SharedString>
});
let is_open = message.is_some();
if let Some(message) = message {
if retained.read(cx).as_ref() != Some(&message) {
retained.update(cx, |stored, cx| {
*stored = Some(message);
cx.notify();
});
}
}
let message = retained.read(cx).clone().unwrap_or_default();
let panel_id = element_id::scoped(id, "field-error-panel");
let panel = gpui::div()
.id(panel_id.clone())
.flex_shrink_0()
.px(px(4.))
.text_size(px(12.))
.line_height(px(16.))
.text_color(cx.colors().danger.color);
let body = gpui::div()
.flex_shrink_0()
.child(message.to_string())
.into_any_element();
collapsible_panel_with_timings(
&panel_id,
is_open,
panel,
body,
Motion::FIELD_ERROR_HEIGHT,
Motion::FIELD_ERROR_OPACITY,
window,
cx,
)
}
#[allow(clippy::too_many_arguments)] fn collapsible_panel_with_timings(
id: &ElementId,
is_open: bool,
panel: gpui::Stateful<gpui::Div>,
body: AnyElement,
height_motion: Motion,
opacity_motion: Motion,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let reduce_motion = ActiveTheme::reduce_motion(cx);
let lifecycle = window.use_keyed_state(element_id::scoped(id, "lifecycle"), cx, |_, _| {
CollapsibleLifecycle::default()
});
let lifecycle_snapshot = *lifecycle.read(cx);
let default_open = lifecycle_snapshot.default_open;
if !lifecycle_snapshot.seen {
lifecycle.update(cx, |state, _| {
state.seen = true;
state.default_open = is_open;
});
}
let exit_ms = height_motion.ms.max(opacity_motion.ms);
let phase = crate::util::panel_phase(
window,
cx,
element_id::scoped(id, "phase"),
is_open,
!reduce_motion,
exit_ms,
);
if phase == crate::util::OverlayPhase::Closed {
return None;
}
let measured = window.use_keyed_state(element_id::scoped(id, "natural-height"), cx, |_, _| {
Rc::new(Cell::new(None::<gpui::Pixels>))
});
let measured_for_listener = measured.clone();
let body = gpui::div()
.flex_shrink_0()
.on_children_prepainted(move |bounds, _, cx| {
let next = bounds.first().map_or(px(0.), |bound| bound.size.height);
measured_for_listener.update(cx, |height, cx| {
if height.get() != Some(next) {
height.set(Some(next));
cx.notify();
}
});
})
.child(body);
let natural_height = measured.read(cx).get();
if natural_height.is_none() {
let panel = panel.flex_shrink_0().child(body);
return Some(
panel
.opacity(if is_open { 1.0 } else { 0.0 })
.into_any_element(),
);
}
let target_height = if is_open {
natural_height.unwrap_or(px(0.))
} else {
px(0.)
};
let initial_open = is_open && !default_open && natural_height.is_some();
let mut height = Tween::keyed_with_initial(
id,
"panel-height",
target_height,
initial_open.then_some(px(0.)),
window,
cx,
);
let mut opacity = Tween::keyed_with_initial(
id,
"panel-opacity",
if is_open { 1.0 } else { 0.0 },
initial_open.then_some(0.0),
window,
cx,
);
height.snap_if_reduced(reduce_motion);
opacity.snap_if_reduced(reduce_motion);
let panel = panel.flex_shrink_0().overflow_hidden().child(body);
let animate_height = height.animates(reduce_motion);
let animate_opacity = opacity.animates(reduce_motion);
if !animate_height && !animate_opacity {
height.settle();
opacity.settle();
return Some(
panel
.h(height.target())
.opacity(opacity.target())
.into_any_element(),
);
}
let height_from = height.from();
let height_to = height.target();
let height_value = height.value();
let opacity_from = opacity.from();
let opacity_to = opacity.target();
let opacity_value = opacity.value();
let animation_id = element_id::scoped(
id,
format!(
"panel-motion-{}-{}",
height.generation(),
opacity.generation()
),
);
let duration_ms = height_motion.ms.max(opacity_motion.ms).max(1);
let panel = panel.with_animation(
animation_id,
gpui::Animation::new(Duration::from_millis(duration_ms)),
move |panel, delta| {
let height_delta = height_motion
.curve
.at((delta * duration_ms as f32 / height_motion.ms.max(1) as f32).min(1.0));
let opacity_delta = opacity_motion
.curve
.at((delta * duration_ms as f32 / opacity_motion.ms.max(1) as f32).min(1.0));
let next_height = height_from + (height_to - height_from) * height_delta;
let next_opacity = opacity_from + (opacity_to - opacity_from) * opacity_delta;
height_value.set(next_height);
opacity_value.set(next_opacity);
panel.h(next_height).opacity(next_opacity)
},
);
Some(panel.into_any_element())
}
#[derive(Clone, Copy, Debug, Default)]
struct CollapsibleLifecycle {
seen: bool,
default_open: bool,
}
pub fn entering_from<E>(
el: E,
id: impl Into<ElementId>,
edge: Edge,
travel: gpui::Pixels,
m: Motion,
cx: &App,
) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if ActiveTheme::reduce_motion(cx) {
return el.into_any_element();
}
el.with_animation(
id.into(),
gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
move |el, delta| {
let remaining = travel * (1.0 - delta);
let el = el.opacity(delta);
match edge {
Edge::Left => el.ml(-remaining),
Edge::Right => el.mr(-remaining),
Edge::Top => el.mt(-remaining),
Edge::Bottom => el.mb(-remaining),
}
},
)
.into_any_element()
}
pub fn exiting_to<E>(
el: E,
id: impl Into<ElementId>,
edge: Edge,
travel: gpui::Pixels,
m: Motion,
cx: &App,
) -> AnyElement
where
E: IntoElement + Styled + 'static,
{
if ActiveTheme::reduce_motion(cx) {
return el.into_any_element();
}
el.with_animation(
id.into(),
gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
move |el, delta| {
let gone = travel * delta;
let el = el.opacity(1.0 - delta);
match edge {
Edge::Left => el.ml(-gone),
Edge::Right => el.mr(-gone),
Edge::Top => el.mt(-gone),
Edge::Bottom => el.mb(-gone),
}
},
)
.into_any_element()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Edge {
Left,
Right,
Top,
Bottom,
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::px;
use herogpui_theme::{set_reduce_motion, set_theme, Theme, ThemeProvider};
#[test]
fn press_scales_resolved_corners_without_erasing_partial_overrides() {
let mut skin = crate::util::round_sx_corners(
gpui::div().rounded(px(2.)),
&gpui::Corners {
top_left: Some(px(12.)),
bottom_right: Some(px(0.)),
..Default::default()
},
);
let corners = pressed_corners(&skin.style().corner_radii, px(8.), 0.5);
assert_eq!(
corners,
gpui::Corners {
top_left: Some(px(6.)),
top_right: Some(px(1.)),
bottom_right: Some(px(0.)),
bottom_left: Some(px(1.)),
}
);
let mut pressed = crate::util::round_sx_corners(gpui::div().rounded(px(4.)), &corners);
assert_eq!(pressed.style().corner_radii.top_left, Some(px(6.).into()));
assert_eq!(
pressed.style().corner_radii.bottom_right,
Some(px(0.).into())
);
let unsupported = gpui::CornersRefinement {
top_left: Some(gpui::rems(1.).into()),
..Default::default()
};
assert_eq!(
pressed_corners(&unsupported, px(8.), 0.5),
gpui::Corners::all(px(4.)).map(|radius| Some(*radius))
);
}
#[test]
fn stable_press_slot_keeps_button_radius_and_group_seams() {
let full = gpui::CornersRefinement {
top_left: Some(gpui::px(12.).into()),
top_right: Some(gpui::px(12.).into()),
bottom_right: Some(gpui::px(12.).into()),
bottom_left: Some(gpui::px(12.).into()),
};
assert_eq!(
resting_slot_corners(&full, px(8.)),
gpui::Corners {
top_left: Some(px(12.)),
top_right: Some(px(12.)),
bottom_right: Some(px(12.)),
bottom_left: Some(px(12.)),
}
);
let group_start = gpui::CornersRefinement {
top_left: Some(gpui::px(12.).into()),
bottom_left: Some(gpui::px(12.).into()),
..Default::default()
};
let corners = resting_slot_corners(&group_start, px(8.));
assert_eq!(corners.top_left, Some(px(12.)));
assert_eq!(corners.bottom_left, Some(px(12.)));
assert_eq!(corners.top_right, None);
assert_eq!(corners.bottom_right, None);
}
#[gpui::test]
fn hover_fade_duration_follows_the_theme_and_zero_resolves_immediately(
cx: &mut gpui::TestAppContext,
) {
cx.update(ThemeProvider::init);
cx.update(|cx| {
assert_eq!(
hover_fade_duration(cx),
Some(Duration::from_millis(TRANSITION_MS)),
"the stock theme uses the button's own 100ms"
);
assert_eq!(
hover_fade_duration_with_override(cx, Some(ACCORDION_TRIGGER_HOVER_MS)),
Some(Duration::from_millis(ACCORDION_TRIGGER_HOVER_MS)),
"Accordion keeps its pinned 150ms declaration"
);
});
cx.update(|cx| {
set_theme(
Theme::builder("instant", Theme::light())
.hover_fade_ms(0)
.build(),
cx,
);
});
cx.update(|cx| {
assert_eq!(hover_fade_duration(cx), None, "zero resolves immediately");
assert_eq!(
hover_fade_duration_with_override(cx, Some(ACCORDION_TRIGGER_HOVER_MS)),
None,
"the theme-wide zero opt-out still suppresses owner overrides"
);
});
cx.update(|cx| {
set_theme(
Theme::builder("slow", Theme::light())
.hover_fade_ms(250)
.build(),
cx,
);
});
cx.update(|cx| {
assert_eq!(hover_fade_duration(cx), Some(Duration::from_millis(250)));
});
cx.update(|cx| set_reduce_motion(true, cx));
cx.update(|cx| {
assert_eq!(
hover_fade_duration(cx),
None,
"reduced motion resolves immediately even with a non-zero token"
);
});
cx.update(|cx| set_reduce_motion(false, cx));
}
#[test]
fn press_inset_matches_the_scale() {
assert!((f32::from(pressed_inset(px(40.))) - 0.6).abs() < 1e-4);
assert!((f32::from(pressed_inset(px(32.))) - 0.48).abs() < 1e-4);
}
#[test]
fn press_preserves_the_outer_footprint() {
for h in [32.0f32, 40.0, 48.0] {
let inset = f32::from(pressed_inset(px(h)));
let shrunk = f32::from(shrink(px(h), pressed_inset(px(h)) + pressed_inset(px(h))));
assert!(
(shrunk + inset * 2.0 - h).abs() < 1e-4,
"footprint changed at {h}"
);
}
}
#[test]
fn cubic_bezier_pins_its_endpoints_and_rises() {
let out = ease_out();
assert!(out(0.0).abs() < 1e-3);
assert!((out(1.0) - 1.0).abs() < 1e-3);
assert!(out(0.5) > 0.5, "ease-out should lead at the midpoint");
let mut prev = 0.0;
for i in 0..=20 {
let v = out(i as f32 / 20.0);
assert!(v >= prev - 1e-4, "ease-out dipped at {i}");
prev = v;
}
let smooth = ease_smooth();
assert!(smooth(0.0).abs() < 1e-3);
assert!((smooth(1.0) - 1.0).abs() < 1e-3);
}
#[test]
#[allow(clippy::float_cmp)] fn drawer_backdrop_uses_its_own_fluid_timing() {
assert_eq!(Motion::DRAWER_BACKDROP_IN.ms, 250);
assert_eq!(Motion::DRAWER_BACKDROP_OUT.ms, 200);
assert_eq!(Motion::DRAWER_BACKDROP_IN.curve, Curve::OutFluid);
assert_eq!(Motion::DRAWER_BACKDROP_OUT.curve, Curve::OutFluid);
assert_eq!(Motion::DRAWER_BACKDROP_IN.scale, 1.0);
assert_eq!(Motion::DRAWER_BACKDROP_OUT.scale, 1.0);
}
#[test]
#[allow(clippy::float_cmp)] fn field_error_uses_independent_height_and_opacity_timelines() {
assert_eq!(Motion::FIELD_ERROR_HEIGHT.ms, 350);
assert_eq!(Motion::FIELD_ERROR_HEIGHT.curve, Curve::Smooth);
assert_eq!(Motion::FIELD_ERROR_OPACITY.ms, 150);
assert_eq!(Motion::FIELD_ERROR_OPACITY.curve, Curve::Out);
assert_eq!(Motion::FIELD_ERROR_HEIGHT.scale, 1.0);
assert_eq!(Motion::FIELD_ERROR_OPACITY.scale, 1.0);
}
#[test]
#[allow(clippy::float_cmp)] fn caret_blink_matches_its_keyframes() {
let at = |t: f32| caret_opacity(t);
for (t, want) in [
(0.0, 1.0),
(0.20, 0.0),
(0.35, 0.0),
(0.50, 0.0),
(0.70, 1.0),
(1.0, 1.0),
] {
assert!((at(t) - want).abs() < 1e-6, "{t} should be {want}");
}
assert!((caret_opacity(0.10) - 0.5).abs() < 1e-6);
assert!((caret_opacity(0.60) - 0.5).abs() < 1e-6);
}
#[test]
fn progress_circle_spin_is_one_linear_turn() {
assert!(progress_circle_spin_turn(0.0).abs() < 1e-6);
assert!((progress_circle_spin_turn(0.25) - std::f32::consts::FRAC_PI_2).abs() < 1e-6);
assert!((progress_circle_spin_turn(1.0) - std::f32::consts::TAU).abs() < 1e-6);
let mut previous = 0.0;
for step in 0..=20 {
let rotation = progress_circle_spin_turn(step as f32 / 20.0);
assert!(rotation >= previous, "spin moved backwards at step {step}");
previous = rotation;
}
}
#[test]
#[allow(clippy::float_cmp)] fn shrink_never_goes_negative() {
assert!(f32::from(shrink(px(1.), px(4.))).abs() < 1e-6);
}
}