use std::time::Duration;
use gpui::Animation;
use gpui_kit_theme::{SpringPreset, SpringTokens, Theme};
const SETTLE_EPSILON: f32 = 0.001;
const MAX_SETTLE: Duration = Duration::from_secs(4);
const BOUNCE_LIMIT: f32 = 0.99;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
pub stiffness: f32,
pub damping: f32,
pub mass: f32,
}
impl Spring {
pub fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
Self {
stiffness: stiffness.max(f32::EPSILON),
damping: damping.max(0.0),
mass: mass.max(f32::EPSILON),
}
}
pub fn perceptual(duration: Duration, bounce: f32) -> Self {
let mass = 1.0;
let seconds = duration.as_secs_f32().max(f32::EPSILON);
let omega = std::f32::consts::TAU / seconds;
let bounce = bounce.clamp(-BOUNCE_LIMIT, BOUNCE_LIMIT);
let zeta = if bounce >= 0.0 {
1.0 - bounce
} else {
1.0 / (1.0 + bounce)
};
Self::new(omega * omega * mass, 2.0 * zeta * omega * mass, mass)
}
pub fn preset(theme: &Theme, preset: SpringPreset) -> Self {
Self::from(theme.spring(preset))
}
fn omega(self) -> f32 {
(self.stiffness / self.mass).sqrt()
}
pub fn damping_ratio(self) -> f32 {
self.damping / (2.0 * (self.stiffness * self.mass).sqrt())
}
pub fn perceptual_duration(self) -> Duration {
Duration::from_secs_f32(std::f32::consts::TAU / self.omega())
}
pub fn bounce(self) -> f32 {
let zeta = self.damping_ratio();
if zeta <= 1.0 {
1.0 - zeta
} else {
1.0 / zeta - 1.0
}
}
pub fn value(self, elapsed: Duration) -> f32 {
self.value_at(elapsed, 0.0).0
}
pub fn value_at(self, elapsed: Duration, velocity: f32) -> (f32, f32) {
let (error, error_rate) = self.error(elapsed, 1.0, -velocity);
(1.0 - error, -error_rate)
}
fn error(self, elapsed: Duration, initial: f32, initial_rate: f32) -> (f32, f32) {
let t = elapsed.as_secs_f32();
if t <= 0.0 {
return (initial, initial_rate);
}
let omega = self.omega();
let zeta = self.damping_ratio();
if zeta < 1.0 {
let damped = omega * (1.0 - zeta * zeta).sqrt();
let a = initial;
let b = (initial_rate + zeta * omega * initial) / damped;
let decay = (-zeta * omega * t).exp();
let (sin, cos) = (damped * t).sin_cos();
(
decay * (a * cos + b * sin),
decay
* ((-zeta * omega * a + damped * b) * cos
+ (-zeta * omega * b - damped * a) * sin),
)
} else if (zeta - 1.0).abs() < f32::EPSILON {
let slope = initial_rate + omega * initial;
let decay = (-omega * t).exp();
let error = initial + slope * t;
(decay * error, decay * (slope - omega * error))
} else {
let root = omega * (zeta * zeta - 1.0).sqrt();
let first = -zeta * omega + root;
let second = -zeta * omega - root;
let c1 = (initial_rate - second * initial) / (first - second);
let c2 = initial - c1;
let (a, b) = (c1 * (first * t).exp(), c2 * (second * t).exp());
(a + b, a * first + b * second)
}
}
pub fn settle_time(self) -> Duration {
self.settle_time_at(0.0)
}
pub fn settle_time_at(self, velocity: f32) -> Duration {
let step = Duration::from_millis(4);
let settled = |elapsed| (1.0 - self.value_at(elapsed, velocity).0).abs() < SETTLE_EPSILON;
let mut elapsed = step;
while elapsed < MAX_SETTLE {
if settled(elapsed) && settled(elapsed + step) {
return elapsed;
}
elapsed += step;
}
MAX_SETTLE
}
pub fn animation(self) -> Animation {
let settle = self.settle_time();
Animation::new(settle).with_easing(move |delta| self.value(settle.mul_f32(delta)))
}
}
impl From<SpringTokens> for Spring {
fn from(tokens: SpringTokens) -> Self {
Self::new(tokens.stiffness, tokens.damping, tokens.mass)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spring(preset: SpringPreset) -> Spring {
Spring::preset(&Theme::studio_dark(), preset)
}
#[test]
fn a_spring_starts_at_rest_and_reaches_its_target() {
for preset in [
SpringPreset::Snappy,
SpringPreset::Smooth,
SpringPreset::Bouncy,
] {
let spring = spring(preset);
assert_eq!(spring.value(Duration::ZERO), 0.0);
let settled = spring.value(spring.settle_time());
assert!(
(settled - 1.0).abs() < 0.01,
"{preset:?} settled at {settled}"
);
}
}
#[test]
fn only_an_underdamped_spring_overshoots() {
let bouncy = spring(SpringPreset::Bouncy);
let smooth = spring(SpringPreset::Smooth);
assert!(bouncy.damping_ratio() < 1.0);
let peak = |spring: Spring| {
(0..400)
.map(|step| spring.value(Duration::from_millis(step * 5)))
.fold(f32::MIN, f32::max)
};
assert!(peak(bouncy) > 1.0, "a bouncy spring passes its target");
assert!(peak(smooth) <= 1.001, "a smooth spring approaches it");
}
#[test]
fn a_stiffer_spring_settles_sooner() {
let stiff = Spring::new(600.0, 30.0, 1.0);
let soft = Spring::new(120.0, 30.0, 1.0);
assert!(stiff.settle_time() < soft.settle_time());
}
#[test]
fn an_overdamped_spring_still_converges() {
let spring = Spring::new(200.0, 80.0, 1.0);
assert!(spring.damping_ratio() > 1.0);
assert!((spring.value(spring.settle_time()) - 1.0).abs() < 0.01);
}
#[test]
fn settle_time_is_bounded_for_a_nearly_static_spring() {
assert_eq!(Spring::new(1.0, 1000.0, 50.0).settle_time(), MAX_SETTLE);
}
fn regimes() -> [Spring; 3] {
[
spring(SpringPreset::Bouncy),
Spring::new(400.0, 40.0, 1.0),
Spring::new(200.0, 80.0, 1.0),
]
}
#[test]
fn released_from_rest_the_general_solution_is_the_step_response() {
for spring in regimes() {
for step in 0..200 {
let elapsed = Duration::from_millis(step * 5);
let (value, _) = spring.value_at(elapsed, 0.0);
assert!(
(value - spring.value(elapsed)).abs() < 1e-4,
"{spring:?} diverged at {elapsed:?}: {value}"
);
}
}
}
#[test]
fn a_carried_velocity_is_the_starting_velocity() {
for spring in regimes() {
let (value, velocity) = spring.value_at(Duration::ZERO, 3.0);
assert_eq!(value, 0.0);
assert!((velocity - 3.0).abs() < 1e-5, "{spring:?} lost its speed");
}
}
#[test]
fn a_spring_released_with_speed_is_further_along_at_once() {
for spring in regimes() {
let early = Duration::from_millis(10);
assert!(
spring.value_at(early, 4.0).0 > spring.value(early),
"{spring:?} did not carry its velocity"
);
}
}
#[test]
fn every_regime_still_settles_from_a_carried_velocity() {
for spring in regimes() {
for velocity in [-4.0, 0.0, 6.0] {
let settle = spring.settle_time_at(velocity);
assert!(settle <= MAX_SETTLE);
let settled = spring.value_at(settle, velocity).0;
assert!(
(settled - 1.0).abs() < 0.01,
"{spring:?} at {velocity} settled on {settled}"
);
}
}
}
fn peak(spring: Spring) -> f32 {
let settle = spring.settle_time();
(0..=400)
.map(|step| spring.value(settle.mul_f32(step as f32 / 400.0)))
.fold(f32::MIN, f32::max)
}
#[test]
fn a_bounce_of_zero_is_critical_damping() {
let spring = Spring::perceptual(Duration::from_millis(400), 0.0);
assert!((spring.damping_ratio() - 1.0).abs() < 1e-4);
assert!(peak(spring) <= 1.0 + SETTLE_EPSILON, "it passed its target");
}
#[test]
fn only_a_positive_bounce_overshoots() {
let duration = Duration::from_millis(400);
let bouncy = Spring::perceptual(duration, 0.4);
let sluggish = Spring::perceptual(duration, -0.4);
assert!((bouncy.damping_ratio() - 0.6).abs() < 1e-4);
assert!((sluggish.damping_ratio() - 1.0 / 0.6).abs() < 1e-4);
assert!(peak(bouncy) > 1.0, "a positive bounce passes its target");
assert!(
peak(sluggish) <= 1.0 + SETTLE_EPSILON,
"a negative bounce must only ever approach it"
);
}
#[test]
fn a_perceptual_spring_is_nearly_arrived_at_the_duration_it_was_given() {
for ms in [150, 400, 900] {
let duration = Duration::from_millis(ms);
for bounce in [0.0, 0.3, 0.6] {
let spring = Spring::perceptual(duration, bounce);
let arrived = spring.value(duration);
assert!(
(arrived - 1.0).abs() < 0.1,
"{ms}ms at bounce {bounce} was {arrived} of the way there"
);
}
}
}
#[test]
fn a_negative_bounce_buys_its_calm_with_time() {
let duration = Duration::from_millis(400);
let arrived = |bounce| Spring::perceptual(duration, bounce).value(duration);
assert!(arrived(-0.2) < arrived(0.0));
assert!(arrived(-0.5) < arrived(-0.2));
assert!(arrived(-0.5) > 0.7, "it is still most of the way there");
}
#[test]
fn duration_and_bounce_survive_the_round_trip() {
for ms in [120, 350, 1000] {
for bounce in [-0.6, -0.2, 0.0, 0.25, 0.75] {
let asked = Duration::from_millis(ms);
let spring = Spring::perceptual(asked, bounce);
let read = spring.perceptual_duration();
assert!(
read.abs_diff(asked) < Duration::from_millis(1),
"{asked:?} at bounce {bounce} came back as {read:?}"
);
assert!(
(spring.bounce() - bounce).abs() < 1e-3,
"bounce {bounce} came back as {}",
spring.bounce()
);
}
}
}
#[test]
fn a_longer_perceptual_duration_is_a_proportionally_longer_spring() {
let short = Spring::perceptual(Duration::from_millis(200), 0.2);
let long = Spring::perceptual(Duration::from_millis(400), 0.2);
let ratio = long.settle_time().as_secs_f32() / short.settle_time().as_secs_f32();
assert!(
(ratio - 2.0).abs() < 0.05,
"twice the duration settled in {ratio} times the time"
);
}
#[test]
fn a_bounce_past_the_limit_is_held_at_it() {
let duration = Duration::from_millis(300);
for bounce in [-4.0, 4.0] {
let spring = Spring::perceptual(duration, bounce);
assert!(
(spring.bounce().abs() - BOUNCE_LIMIT).abs() < 1e-3,
"bounce {bounce} became {}",
spring.bounce()
);
assert!(
spring.damping_ratio() > 0.0,
"bounce {bounce} lost its damping"
);
}
assert!(
Spring::perceptual(duration, 4.0)
.value(duration)
.is_finite()
);
assert!(
Spring::perceptual(duration, -4.0)
.value(duration)
.is_finite()
);
}
#[test]
fn the_token_presets_are_untouched_by_the_perceptual_way_in() {
let tokens = Theme::studio_dark().spring(SpringPreset::Smooth);
let preset = spring(SpringPreset::Smooth);
assert_eq!(
preset,
Spring::new(tokens.stiffness, tokens.damping, tokens.mass)
);
assert_eq!(preset.stiffness, 180.0);
assert_eq!(preset.damping, 26.0);
assert_eq!(preset.mass, 1.0);
}
#[test]
fn a_spring_thrown_the_wrong_way_takes_longer_to_come_to_rest() {
let spring = spring(SpringPreset::Smooth);
assert!(spring.settle_time_at(-6.0) > spring.settle_time());
}
}