use std::cell::RefCell;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use gpui::{
Animation, AnimationElement, App, ElementId, EntityId, Global, Hsla, IntoElement, Rgba,
SharedString, Styled, Window, px,
};
pub use gpui::AnimationExt;
pub mod phase;
const PULSE_TICK: Duration = Duration::from_millis(33);
const PULSE_LEASE: Duration = Duration::from_millis(300);
struct PulseClock {
epoch: Instant,
leases: HashMap<EntityId, Instant>,
running: bool,
}
impl Global for PulseClock {}
impl Default for PulseClock {
fn default() -> Self {
Self {
epoch: Instant::now(),
leases: HashMap::new(),
running: false,
}
}
}
pub fn pulse_delta(spec: &MotionSpec, view: EntityId, cx: &mut App) -> f32 {
if cx.reduce_motion() {
return 0.0;
}
let clock = cx.default_global::<PulseClock>();
clock.leases.insert(view, Instant::now() + PULSE_LEASE);
let period = spec.total().as_secs_f32();
let phase = (clock.epoch.elapsed().as_secs_f32() / period).fract();
if !clock.running {
clock.running = true;
cx.spawn(async move |cx| {
loop {
cx.background_executor().timer(PULSE_TICK).await;
let parked = cx.update(|cx| {
let clock = cx.default_global::<PulseClock>();
let now = Instant::now();
clock.leases.retain(|_, until| *until > now);
if clock.leases.is_empty() {
clock.running = false;
return true;
}
let views: Vec<EntityId> = clock.leases.keys().copied().collect();
for view in views {
cx.notify(view);
}
false
});
if parked {
break;
}
}
})
.detach();
}
phase
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CubicBezier {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
}
impl CubicBezier {
pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
Self { x1, y1, x2, y2 }
}
fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
let c = 3.0 * a;
let bb = 3.0 * (b - a) - c;
let aa = 1.0 - c - bb;
(aa, bb, c)
}
fn sample_x(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.x1, self.x2);
((a * t + b) * t + c) * t
}
fn sample_y(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.y1, self.y2);
((a * t + b) * t + c) * t
}
fn sample_x_derivative(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.x1, self.x2);
(3.0 * a * t + 2.0 * b) * t + c
}
fn solve_t_for_x(&self, x: f32) -> f32 {
let mut t = x;
for _ in 0..8 {
let err = self.sample_x(t) - x;
if err.abs() < 1e-6 {
return t;
}
let d = self.sample_x_derivative(t);
if d.abs() < 1e-6 {
break;
}
t -= err / d;
}
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
for _ in 0..32 {
let mid = (lo + hi) / 2.0;
if self.sample_x(mid) < x {
lo = mid
} else {
hi = mid
}
}
(lo + hi) / 2.0
}
pub fn eval(&self, x: f32) -> f32 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
}
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
move |x| self.eval(x)
}
}
pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionSpec {
pub duration_ms: u64,
pub delay_ms: u64,
pub curve: CubicBezier,
}
impl MotionSpec {
pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
Self {
duration_ms,
delay_ms: 0,
curve,
}
}
pub const fn with_delay(mut self, delay_ms: u64) -> Self {
self.delay_ms = delay_ms;
self
}
pub fn total(&self) -> Duration {
Duration::from_millis(self.delay_ms + self.duration_ms)
}
pub fn progress(&self, raw_delta: f32) -> f32 {
let total = (self.delay_ms + self.duration_ms) as f32;
if total <= 0.0 || self.duration_ms == 0 {
return 1.0;
}
let t =
(raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
self.curve.eval(t.clamp(0.0, 1.0))
}
pub fn animation(&self) -> Animation {
let spec = *self;
Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
}
pub fn repeating(&self) -> Animation {
Animation::new(self.total()).repeat()
}
}
pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, FADE_IN.animation(), |el, t| {
el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
})
}
pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
}
pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, MENU_IN.animation(), |el, t| {
el.relative()
.opacity(0.3 + 0.7 * t)
.top(px(-2.0 * (1.0 - t)))
})
}
pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, MENU_OUT.animation(), move |el, _| {
el.relative().opacity(1.0 - t).top(px(-2.0 * t))
})
}
pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, DIALOG_IN.animation(), |el, t| {
el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
})
}
pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
el.opacity(1.0 - t).top(px(-6.0 * t))
})
}
pub use crate::phase::{
PULSE_MIN_OPACITY, PULSE_MIN_SCALE, PULSE_STAGGER, gspin_opacity, pulse_opacity, pulse_scale,
pulse_wave, staggered_phase,
};
pub fn matrix_wave(raw_delta: f32, wave_index: usize, wave_count: usize) -> f32 {
let count = wave_count.max(1) as f32;
pulse_wave(staggered_phase(raw_delta, wave_index, 1.0 / count))
}
pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * t
}
#[derive(Debug, Clone, Copy)]
struct FadeEntry {
origin: f32,
target: f32,
started: Instant,
seen: u64,
}
impl FadeEntry {
fn value(&self, now: Instant, duration: Duration) -> f32 {
let elapsed = now.saturating_duration_since(self.started);
if duration.is_zero() || elapsed >= duration {
return self.target;
}
let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
}
fn settled(&self, now: Instant, duration: Duration) -> bool {
self.origin == self.target || now.saturating_duration_since(self.started) >= duration
}
}
#[derive(Default)]
pub struct HoverFades {
entries: HashMap<String, FadeEntry>,
frame: u64,
}
impl HoverFades {
fn duration() -> Duration {
HOVER_FADE.total().mul_f32(speed_scale())
}
pub fn set_at(&mut self, key: &str, hovered: bool, reduced: bool, now: Instant) {
let target = if hovered { 1.0 } else { 0.0 };
let duration = Self::duration();
let current = self
.entries
.get(key)
.map(|e| e.value(now, duration))
.unwrap_or(0.0);
if target == 0.0 && !self.entries.contains_key(key) {
return; }
let origin = if reduced { target } else { current };
let seen = self.frame;
self.entries.insert(
key.to_string(),
FadeEntry {
origin,
target,
started: now,
seen,
},
);
}
pub fn value_at(&mut self, key: &str, now: Instant) -> f32 {
let frame = self.frame;
match self.entries.get_mut(key) {
Some(entry) => {
entry.seen = frame;
entry.value(now, Self::duration())
}
None => 0.0,
}
}
pub fn tick_at(&mut self, now: Instant) -> bool {
self.frame += 1;
let frame = self.frame;
let duration = Self::duration();
let mut active = false;
self.entries.retain(|_, entry| {
if entry.seen + 1 < frame {
return false;
}
let settled = entry.settled(now, duration);
if !settled {
active = true;
}
!(settled && entry.target == 0.0)
});
active
}
}
thread_local! {
static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
}
pub fn hover_t(key: &str) -> f32 {
HOVER_FADES.with(|fades| fades.borrow_mut().value_at(key, Instant::now()))
}
pub fn set_hover(key: &str, hovered: bool, reduced: bool) {
HOVER_FADES.with(|fades| {
fades
.borrow_mut()
.set_at(key, hovered, reduced, Instant::now())
});
}
pub fn hover_listener(
key: impl Into<SharedString>,
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
let key = key.into();
move |hovered, window, cx| {
set_hover(&key, *hovered, reduced_motion(cx));
window.refresh();
}
}
pub fn hover_fades_active() -> bool {
HOVER_FADES.with(|fades| fades.borrow_mut().tick_at(Instant::now()))
}
pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
let t = t.clamp(0.0, 1.0);
if t <= 0.0 {
return from;
}
if t >= 1.0 {
return to;
}
let (f, g) = (Rgba::from(from), Rgba::from(to));
let a = lerp(f.a, g.a, t);
if a <= f32::EPSILON {
return Hsla::from(Rgba { a: 0.0, ..g });
}
Hsla::from(Rgba {
r: lerp(f.r * f.a, g.r * g.a, t) / a,
g: lerp(f.g * f.a, g.g * g.a, t) / a,
b: lerp(f.b * f.a, g.b * g.a, t) / a,
a,
})
}
pub fn hover_blend(key: &str, rest: Hsla, hover: Hsla) -> Hsla {
mix(rest, hover, hover_t(key))
}
pub fn speed_scale() -> f32 {
static SCALE: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
*SCALE.get_or_init(|| {
std::env::var("BEZEL_MOTION_SCALE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|s| s.is_finite())
.map(|s| s.clamp(0.01, 100.0))
.unwrap_or(1.0)
})
}
pub fn set_reduced_motion(cx: &mut App, reduced: bool) {
cx.set_reduce_motion(reduced);
}
pub fn reduced_motion(cx: &App) -> bool {
cx.reduce_motion()
}
#[cfg(test)]
mod tests {
#[test]
fn eval_never_escapes_unit_interval_dense_sweep() {
for curve in [EASE_OUT_EXPO, EASE_OUT, EASE, EASE_RESORT, EASE_IN_OUT] {
for i in 0..=100_000u32 {
let x = i as f32 / 100_000.0;
let y = curve.eval(x);
assert!((0.0..=1.0).contains(&y), "eval({x}) = {y} escaped [0,1]");
}
for x in [0.999_999f32, 0.999_999_9, 1.0 - f32::EPSILON] {
let y = curve.eval(x);
assert!((0.0..=1.0).contains(&y), "eval({x}) = {y} escaped [0,1]");
}
}
}
use super::*;
fn assert_close(actual: f32, expected: f32, tol: f32, ctx: &str) {
assert!(
(actual - expected).abs() <= tol,
"{ctx}: got {actual}, expected {expected} ±{tol}"
);
}
#[test]
fn bezier_linear_is_identity() {
let linear = CubicBezier::new(0.0, 0.0, 1.0, 1.0);
for x in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0] {
assert_close(linear.eval(x), x, 1e-4, "linear");
}
}
#[test]
fn bezier_known_values() {
let cases: [(&str, CubicBezier, [f32; 5]); 3] = [
(
"expo",
EASE_OUT_EXPO,
[0.494391, 0.825622, 0.971779, 0.997677, 0.999878],
),
(
"ease-out",
EASE_OUT,
[0.160572, 0.378138, 0.684643, 0.906535, 0.982973],
),
(
"ease",
EASE,
[0.094796, 0.408511, 0.802403, 0.960459, 0.994316],
),
];
for (name, curve, expected) in cases {
for (x, want) in [0.1, 0.25, 0.5, 0.75, 0.9].into_iter().zip(expected) {
assert_close(curve.eval(x), want, 1e-3, name);
}
}
}
#[test]
fn bezier_endpoints_and_clamping() {
for curve in [EASE_OUT_EXPO, EASE_OUT, EASE, EASE_RESORT, EASE_IN_OUT] {
assert_eq!(curve.eval(0.0), 0.0);
assert_eq!(curve.eval(1.0), 1.0);
assert_eq!(curve.eval(-0.5), 0.0);
assert_eq!(curve.eval(1.5), 1.0);
}
}
#[test]
fn bezier_is_monotonic_for_catalog_curves() {
for curve in [EASE_OUT_EXPO, EASE_OUT, EASE, EASE_RESORT, EASE_IN_OUT] {
let mut last = 0.0;
for i in 0..=100 {
let y = curve.eval(i as f32 / 100.0);
assert!(y >= last - 1e-4, "monotonicity violated at {i}");
last = y;
}
}
}
#[test]
fn spec_delay_holds_then_runs() {
assert_eq!(SPLASH_OUT.total(), Duration::from_millis(650));
assert_eq!(SPLASH_OUT.progress(0.0), 0.0);
assert_eq!(SPLASH_OUT.progress(0.2), 0.0);
assert_eq!(SPLASH_OUT.progress(1.0), 1.0);
assert_eq!(SPLASH_OUT.progress(2.0), 1.0);
let mid = SPLASH_OUT.progress(0.65);
assert!(mid > 0.0 && mid < 1.0);
assert_close(
FADE_IN.progress(0.5),
EASE_OUT_EXPO.eval(0.5),
1e-6,
"no-delay",
);
}
#[test]
fn catalog_timings_match_the_source() {
assert_eq!(FADE_IN.duration_ms, 500);
assert_eq!(FADE_QUICK.duration_ms, 150);
assert_eq!(MENU_IN.duration_ms, 140);
assert_eq!(DIALOG_IN.duration_ms, 180);
assert_eq!((SPLASH_OUT.duration_ms, SPLASH_OUT.delay_ms), (500, 150));
assert_eq!(RESIZE.duration_ms, 200);
assert_eq!(TAB_SLIDE.duration_ms, 150);
assert_eq!(COLLAPSE.duration_ms, 180);
assert_eq!(CHEVRON.duration_ms, 200);
assert_eq!(PULSE.duration_ms, 2400);
assert_eq!(GRADIENT_SPIN.duration_ms, 750);
assert_eq!(EASE_OUT_EXPO, CubicBezier::new(0.16, 1.0, 0.3, 1.0));
}
#[test]
fn pulse_wave_endpoints() {
assert_close(pulse_wave(0.0), 0.0, 1e-6, "wave start");
assert_close(pulse_wave(0.5), 1.0, 1e-6, "wave peak");
assert_close(pulse_wave(1.0), 0.0, 1e-6, "wave end");
assert_close(pulse_opacity(0.0), 0.08, 1e-6, "opacity floor");
assert_close(pulse_opacity(0.5), 1.0, 1e-6, "opacity peak");
assert_close(pulse_scale(0.0), 0.9, 1e-6, "scale floor");
assert_close(pulse_scale(0.5), 1.0, 1e-6, "scale peak");
}
#[test]
fn stagger_wraps_and_orders_cells() {
assert_close(staggered_phase(0.0, 0, PULSE_STAGGER), 0.0, 1e-6, "cell 0");
assert_close(
staggered_phase(0.0, 1, PULSE_STAGGER),
1.0 - PULSE_STAGGER,
1e-5,
"cell 1 wraps",
);
assert_close(
staggered_phase(0.3, 2, PULSE_STAGGER),
staggered_phase(0.3 + 1.0, 2, PULSE_STAGGER),
2e-6,
"periodic",
);
let peak0 = matrix_wave(0.5, 0, 5);
assert_close(peak0, 1.0, 1e-5, "diag 0 peak at half period");
}
#[test]
fn lerp_basics() {
assert_eq!(lerp(208.0, 400.0, 0.0), 208.0);
assert_eq!(lerp(208.0, 400.0, 1.0), 400.0);
assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
}
#[test]
fn hover_fade_ramps_and_reverses_continuously() {
let mut fades = HoverFades::default();
let t0 = Instant::now();
let ms = |m: u64| t0 + Duration::from_millis(m);
fades.set_at("pill", true, false, t0);
assert_eq!(fades.value_at("pill", t0), 0.0);
let mid = fades.value_at("pill", ms(75));
assert!(mid > 0.0 && mid < 1.0, "mid-flight enter: {mid}");
assert_eq!(fades.value_at("pill", ms(150)), 1.0);
assert_eq!(fades.value_at("pill", ms(400)), 1.0, "clamps past the end");
fades.set_at("pill", true, false, t0);
let at_flip = fades.value_at("pill", ms(75));
fades.set_at("pill", false, false, ms(75));
let after_flip = fades.value_at("pill", ms(75));
assert!(
(after_flip - at_flip).abs() < 1e-4,
"continuity: {at_flip} vs {after_flip}"
);
let falling = fades.value_at("pill", ms(140));
assert!(falling < after_flip, "fades back down");
assert_eq!(fades.value_at("pill", ms(225)), 0.0, "lands at rest");
}
#[test]
fn hover_fade_reduced_motion_snaps() {
let mut fades = HoverFades::default();
let t0 = Instant::now();
fades.set_at("row", true, true, t0);
assert_eq!(fades.value_at("row", t0), 1.0, "enter snaps to 1");
fades.set_at("row", false, true, t0);
assert_eq!(fades.value_at("row", t0), 0.0, "leave snaps to 0");
}
#[test]
fn hover_fade_leave_without_enter_is_inert() {
let mut fades = HoverFades::default();
let t0 = Instant::now();
fades.set_at("ghost", false, false, t0);
assert!(fades.entries.is_empty(), "no entry for a leave-only key");
assert_eq!(fades.value_at("ghost", t0), 0.0);
}
#[test]
fn hover_tick_reports_flight_and_prunes() {
let mut fades = HoverFades::default();
let t0 = Instant::now();
let ms = |m: u64| t0 + Duration::from_millis(m);
fades.set_at("a", true, false, t0);
assert!(fades.tick_at(ms(50)));
fades.value_at("a", ms(50));
assert!(fades.tick_at(ms(100)));
fades.value_at("a", ms(100));
assert!(!fades.tick_at(ms(200)));
fades.value_at("a", ms(200));
assert_eq!(fades.value_at("a", ms(250)), 1.0);
fades.set_at("a", false, false, ms(250));
assert!(fades.tick_at(ms(300)));
fades.value_at("a", ms(300));
assert!(!fades.tick_at(ms(500)), "settled at rest");
assert!(fades.entries.is_empty(), "rest entries are pruned");
}
#[test]
fn hover_tick_evicts_unread_entries() {
let mut fades = HoverFades::default();
let t0 = Instant::now();
let ms = |m: u64| t0 + Duration::from_millis(m);
fades.set_at("menu-row", true, false, t0);
fades.tick_at(ms(16));
fades.value_at("menu-row", ms(16)); fades.tick_at(ms(32)); fades.tick_at(ms(48)); assert!(fades.entries.is_empty(), "unread entry evicted");
assert_eq!(fades.value_at("menu-row", ms(64)), 0.0);
}
#[test]
fn mix_endpoints_and_transparent_blend() {
let rest = bezel_theme::neutral(0.235);
let hover = bezel_theme::neutral(0.29);
assert_eq!(mix(rest, hover, 0.0), rest);
assert_eq!(mix(rest, hover, 1.0), hover);
assert_eq!(mix(rest, hover, -1.0), rest, "t clamps low");
assert_eq!(mix(rest, hover, 2.0), hover, "t clamps high");
let mid = mix(rest, hover, 0.5);
assert!(mid.l > rest.l && mid.l < hover.l, "mid lightness {}", mid.l);
let _guard = bezel_theme::lock_appearance();
let wash = bezel_theme::ink(0.06);
let half = mix(gpui::transparent_black(), wash, 0.5);
assert!((half.a - 0.03).abs() < 1e-4, "alpha midpoint {}", half.a);
let half_rgba = Rgba::from(half);
assert!(
half_rgba.r > 0.99 && half_rgba.g > 0.99 && half_rgba.b > 0.99,
"white wash keeps its hue: {half_rgba:?}"
);
}
#[test]
fn hover_spec_matches_tailwind_transition_colors() {
assert_eq!(HOVER_FADE.duration_ms, 150);
assert_eq!(HOVER_FADE.delay_ms, 0);
assert_eq!(EASE_TAILWIND, CubicBezier::new(0.4, 0.0, 0.2, 1.0));
}
#[test]
fn gspin_pulse_shape() {
assert_close(gspin_opacity(0.0, 0.1), 1.0, 1e-6, "cycle start");
assert_close(gspin_opacity(0.45, 0.1), 0.1, 1e-6, "fully dim");
assert_close(gspin_opacity(0.9, 0.1), 0.1, 1e-6, "rest band");
assert_close(gspin_opacity(1.0, 0.1), 1.0, 1e-6, "wraps to full");
let mid_fall = gspin_opacity(0.2, 0.1);
assert!(mid_fall > 0.1 && mid_fall < 1.0, "eases down");
let mid_rise = gspin_opacity(0.96, 0.1);
assert!(mid_rise > 0.1 && mid_rise < 1.0, "eases up");
}
}