Skip to main content

guise/anim/
spring.rs

1//! Spring easing from a closed-form damped harmonic oscillator. No
2//! simulation loop: position is evaluated directly at normalized time, so a
3//! spring plugs into gpui's `with_easing` like any other curve.
4
5/// A mass-1 spring. Stiffness sets speed, damping sets wobble: `damping <
6/// 2·√stiffness` is underdamped (overshoots and rings), anything above
7/// settles without crossing 1.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Spring {
10  pub stiffness: f32,
11  pub damping: f32,
12}
13
14impl Default for Spring {
15  fn default() -> Self {
16    // A gentle UI spring: slight overshoot, settles fast.
17    Spring {
18      stiffness: 170.0,
19      damping: 22.0,
20    }
21  }
22}
23
24impl Spring {
25  pub fn new(stiffness: f32, damping: f32) -> Self {
26    Spring {
27      stiffness: stiffness.max(1.0),
28      damping: damping.max(0.0),
29    }
30  }
31
32  /// A bouncier preset (visible ring before settling).
33  pub fn wobbly() -> Self {
34    Spring {
35      stiffness: 180.0,
36      damping: 12.0,
37    }
38  }
39
40  /// No overshoot at all (critically damped).
41  pub fn stiff() -> Self {
42    Spring {
43      stiffness: 210.0,
44      damping: 2.0 * 210.0_f32.sqrt(),
45    }
46  }
47
48  /// Spring position at time `seconds`, from 0 toward 1.
49  pub fn position(self, seconds: f32) -> f32 {
50    if seconds <= 0.0 {
51      return 0.0;
52    }
53    let w0 = self.stiffness.sqrt();
54    let zeta = self.damping / (2.0 * w0);
55    if zeta < 1.0 {
56      // Underdamped: decaying cosine around the target.
57      let wd = w0 * (1.0 - zeta * zeta).sqrt();
58      let decay = (-zeta * w0 * seconds).exp();
59      1.0 - decay * ((wd * seconds).cos() + (zeta * w0 / wd) * (wd * seconds).sin())
60    } else {
61      // Critically damped / overdamped: pure approach.
62      let decay = (-w0 * seconds).exp();
63      1.0 - decay * (1.0 + w0 * seconds)
64    }
65  }
66
67  /// Seconds until the spring stays within 1% of the target — pass this as
68  /// the `Animation` duration so the curve completes on screen.
69  pub fn settle_seconds(self) -> f32 {
70    let w0 = self.stiffness.sqrt();
71    let zeta = (self.damping / (2.0 * w0)).min(1.0);
72    // The underdamped envelope e^(-ζωt) reaches 1% at 4.6 time constants
73    // (ln 0.01 ≈ -4.6); critical damping's extra (1 + ωt) factor pushes
74    // that to ~6.6, so scale with ζ. Clamped for the undamped edge case.
75    let constants = 4.6 + 2.0 * zeta;
76    (constants / (zeta * w0).max(0.5)).min(10.0)
77  }
78
79  /// The spring as a normalized easing over its settle duration.
80  pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
81    let total = self.settle_seconds();
82    move |t: f32| {
83      if t >= 1.0 {
84        1.0
85      } else {
86        self.position(t * total)
87      }
88    }
89  }
90}
91
92#[cfg(test)]
93mod tests {
94  use super::*;
95
96  #[test]
97  fn starts_at_zero_ends_at_one() {
98    for spring in [Spring::default(), Spring::wobbly(), Spring::stiff()] {
99      let ease = spring.easing();
100      assert_eq!(ease(0.0), 0.0);
101      assert_eq!(ease(1.0), 1.0);
102      assert!((ease(0.999) - 1.0).abs() < 0.02);
103    }
104  }
105
106  #[test]
107  fn underdamped_overshoots() {
108    let ease = Spring::wobbly().easing();
109    let max = (0..=1000)
110      .map(|i| ease(i as f32 / 1000.0))
111      .fold(f32::MIN, f32::max);
112    assert!(max > 1.01, "wobbly spring should ring past 1, got {max}");
113  }
114
115  #[test]
116  fn critically_damped_never_crosses_one() {
117    let ease = Spring::stiff().easing();
118    for i in 0..=1000 {
119      assert!(ease(i as f32 / 1000.0) <= 1.0 + 1e-4);
120    }
121  }
122
123  #[test]
124  fn position_is_monotone_toward_target_early_on() {
125    let spring = Spring::default();
126    let quarter = spring.position(spring.settle_seconds() * 0.25);
127    let half = spring.position(spring.settle_seconds() * 0.5);
128    assert!(quarter > 0.1);
129    assert!(half >= quarter * 0.9);
130  }
131
132  #[test]
133  fn settle_time_is_sane() {
134    for spring in [Spring::default(), Spring::wobbly(), Spring::stiff()] {
135      let s = spring.settle_seconds();
136      assert!(s > 0.05 && s < 10.0, "settle {s}");
137    }
138  }
139}