Skip to main content

gpui_kit/motion/
spring.rs

1//! A closed-form damped-spring solver.
2//!
3//! Springs are described the way designers reason about them (stiffness,
4//! damping, mass) and evaluated analytically, so a value at any instant costs
5//! the same regardless of frame rate and never accumulates integration drift.
6
7use std::time::Duration;
8
9use gpui::Animation;
10use gpui_kit_theme::{SpringPreset, SpringTokens, Theme};
11
12/// The fraction of the remaining distance treated as arrived.
13const SETTLE_EPSILON: f32 = 0.001;
14/// A spring that has not settled by this point is treated as settled anyway,
15/// so an over-soft configuration cannot animate forever.
16const MAX_SETTLE: Duration = Duration::from_secs(4);
17/// How far a bounce may be pushed either way. The mapping below runs to a
18/// damping ratio of zero at 1 and to infinity at -1, neither of which is a
19/// spring that ever arrives, so the ends are held just short of both.
20const BOUNCE_LIMIT: f32 = 0.99;
21
22/// A damped spring, solved in closed form so a position is a function of time
23/// rather than of how many frames happened to be delivered.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct Spring {
26    pub stiffness: f32,
27    pub damping: f32,
28    pub mass: f32,
29}
30
31impl Spring {
32    pub fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
33        Self {
34            stiffness: stiffness.max(f32::EPSILON),
35            damping: damping.max(0.0),
36            mass: mass.max(f32::EPSILON),
37        }
38    }
39
40    /// A spring described the way a design decision is: how long it takes and
41    /// how much it overshoots.
42    ///
43    /// Stiffness, damping and mass are three numbers for two decisions, and
44    /// neither decision is either of the three. This is the same
45    /// parameterisation as SwiftUI's `Spring(duration:bounce:)`, and it is a
46    /// change of variables rather than an approximation:
47    ///
48    /// - mass is fixed at 1. A spring's behaviour depends on stiffness and
49    ///   damping only through `k/m` and `c/m`, so nothing is lost by it;
50    /// - `duration` is the period of the undamped oscillation, which is what
51    ///   sets the pace whatever the damping does:
52    ///   `omega = 2 * PI / duration`, and `stiffness = omega^2 * mass`;
53    /// - `bounce` is the damping ratio turned inside out, so that 0 is
54    ///   critically damped whichever side of it the value is on:
55    ///   `zeta = 1 - bounce` for a positive bounce, which reaches the
56    ///   undamped `zeta = 0` at 1, and `zeta = 1 / (1 + bounce)` for a
57    ///   negative one, which grows without bound toward -1. Damping follows
58    ///   from the ratio: `damping = 2 * zeta * sqrt(stiffness * mass)`, which
59    ///   with the frequency above is `4 * PI * zeta * mass / duration`.
60    ///
61    /// So a bounce of 0 settles without passing its target, a positive bounce
62    /// overshoots and comes back, and a negative one crawls in. The bounce is
63    /// held inside `-0.99..=0.99`.
64    ///
65    /// [`Spring::new`] is unchanged and remains the way in for a spring whose
66    /// three constants are already known, a token preset included.
67    pub fn perceptual(duration: Duration, bounce: f32) -> Self {
68        let mass = 1.0;
69        let seconds = duration.as_secs_f32().max(f32::EPSILON);
70        let omega = std::f32::consts::TAU / seconds;
71        let bounce = bounce.clamp(-BOUNCE_LIMIT, BOUNCE_LIMIT);
72        let zeta = if bounce >= 0.0 {
73            1.0 - bounce
74        } else {
75            1.0 / (1.0 + bounce)
76        };
77        Self::new(omega * omega * mass, 2.0 * zeta * omega * mass, mass)
78    }
79
80    pub fn preset(theme: &Theme, preset: SpringPreset) -> Self {
81        Self::from(theme.spring(preset))
82    }
83
84    /// Undamped angular frequency.
85    fn omega(self) -> f32 {
86        (self.stiffness / self.mass).sqrt()
87    }
88
89    /// Damping ratio: below one oscillates, one settles fastest, above one crawls.
90    pub fn damping_ratio(self) -> f32 {
91        self.damping / (2.0 * (self.stiffness * self.mass).sqrt())
92    }
93
94    /// The duration half of [`Spring::perceptual`], for a spring built any
95    /// other way: the period of the undamped oscillation.
96    ///
97    /// It is not the settle time. A spring is still perceptibly arriving after
98    /// its perceptual duration — at a bounce of 0 it is roughly 99% of the way
99    /// there — and [`Spring::settle_time`] is the honest end of the motion.
100    pub fn perceptual_duration(self) -> Duration {
101        Duration::from_secs_f32(std::f32::consts::TAU / self.omega())
102    }
103
104    /// The bounce half, inverted from the damping ratio.
105    pub fn bounce(self) -> f32 {
106        let zeta = self.damping_ratio();
107        if zeta <= 1.0 {
108            1.0 - zeta
109        } else {
110            1.0 / zeta - 1.0
111        }
112    }
113
114    /// Normalized step response: 0 at rest, approaching 1 as it settles.
115    pub fn value(self, elapsed: Duration) -> f32 {
116        self.value_at(elapsed, 0.0).0
117    }
118
119    /// Normalized step response for a spring that was already moving when it
120    /// was aimed here.
121    ///
122    /// `velocity` is the speed carried into the motion, in units of the full
123    /// distance per second and positive toward the target. The pair is the
124    /// value and its own velocity, so a caller that retargets again can hand
125    /// the motion on rather than restarting it.
126    pub fn value_at(self, elapsed: Duration, velocity: f32) -> (f32, f32) {
127        // Distance still to travel starts at the whole of it, and closing that
128        // distance is what a positive carried velocity does.
129        let (error, error_rate) = self.error(elapsed, 1.0, -velocity);
130        (1.0 - error, -error_rate)
131    }
132
133    /// The remaining distance and its rate of change at `elapsed`, for a
134    /// spring released with error `initial` changing at `initial_rate`.
135    fn error(self, elapsed: Duration, initial: f32, initial_rate: f32) -> (f32, f32) {
136        let t = elapsed.as_secs_f32();
137        if t <= 0.0 {
138            return (initial, initial_rate);
139        }
140        let omega = self.omega();
141        let zeta = self.damping_ratio();
142        if zeta < 1.0 {
143            let damped = omega * (1.0 - zeta * zeta).sqrt();
144            let a = initial;
145            let b = (initial_rate + zeta * omega * initial) / damped;
146            let decay = (-zeta * omega * t).exp();
147            let (sin, cos) = (damped * t).sin_cos();
148            (
149                decay * (a * cos + b * sin),
150                decay
151                    * ((-zeta * omega * a + damped * b) * cos
152                        + (-zeta * omega * b - damped * a) * sin),
153            )
154        } else if (zeta - 1.0).abs() < f32::EPSILON {
155            let slope = initial_rate + omega * initial;
156            let decay = (-omega * t).exp();
157            let error = initial + slope * t;
158            (decay * error, decay * (slope - omega * error))
159        } else {
160            let root = omega * (zeta * zeta - 1.0).sqrt();
161            let first = -zeta * omega + root;
162            let second = -zeta * omega - root;
163            let c1 = (initial_rate - second * initial) / (first - second);
164            let c2 = initial - c1;
165            let (a, b) = (c1 * (first * t).exp(), c2 * (second * t).exp());
166            (a + b, a * first + b * second)
167        }
168    }
169
170    /// How long until the spring stays within one part in a thousand of its target.
171    pub fn settle_time(self) -> Duration {
172        self.settle_time_at(0.0)
173    }
174
175    /// The same, for a spring released with `velocity` already carried into
176    /// the motion: one that is travelling fast needs longer to come to rest.
177    pub fn settle_time_at(self, velocity: f32) -> Duration {
178        let step = Duration::from_millis(4);
179        let settled = |elapsed| (1.0 - self.value_at(elapsed, velocity).0).abs() < SETTLE_EPSILON;
180        let mut elapsed = step;
181        while elapsed < MAX_SETTLE {
182            if settled(elapsed) && settled(elapsed + step) {
183                return elapsed;
184            }
185            elapsed += step;
186        }
187        MAX_SETTLE
188    }
189
190    /// Expresses the spring as a GPUI animation, so it can drive
191    /// `with_animation` alongside curve-based motion.
192    pub fn animation(self) -> Animation {
193        let settle = self.settle_time();
194        Animation::new(settle).with_easing(move |delta| self.value(settle.mul_f32(delta)))
195    }
196}
197
198impl From<SpringTokens> for Spring {
199    fn from(tokens: SpringTokens) -> Self {
200        Self::new(tokens.stiffness, tokens.damping, tokens.mass)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn spring(preset: SpringPreset) -> Spring {
209        Spring::preset(&Theme::studio_dark(), preset)
210    }
211
212    #[test]
213    fn a_spring_starts_at_rest_and_reaches_its_target() {
214        for preset in [
215            SpringPreset::Snappy,
216            SpringPreset::Smooth,
217            SpringPreset::Bouncy,
218        ] {
219            let spring = spring(preset);
220            assert_eq!(spring.value(Duration::ZERO), 0.0);
221            let settled = spring.value(spring.settle_time());
222            assert!(
223                (settled - 1.0).abs() < 0.01,
224                "{preset:?} settled at {settled}"
225            );
226        }
227    }
228
229    #[test]
230    fn only_an_underdamped_spring_overshoots() {
231        let bouncy = spring(SpringPreset::Bouncy);
232        let smooth = spring(SpringPreset::Smooth);
233        assert!(bouncy.damping_ratio() < 1.0);
234
235        let peak = |spring: Spring| {
236            (0..400)
237                .map(|step| spring.value(Duration::from_millis(step * 5)))
238                .fold(f32::MIN, f32::max)
239        };
240        assert!(peak(bouncy) > 1.0, "a bouncy spring passes its target");
241        assert!(peak(smooth) <= 1.001, "a smooth spring approaches it");
242    }
243
244    #[test]
245    fn a_stiffer_spring_settles_sooner() {
246        let stiff = Spring::new(600.0, 30.0, 1.0);
247        let soft = Spring::new(120.0, 30.0, 1.0);
248        assert!(stiff.settle_time() < soft.settle_time());
249    }
250
251    #[test]
252    fn an_overdamped_spring_still_converges() {
253        let spring = Spring::new(200.0, 80.0, 1.0);
254        assert!(spring.damping_ratio() > 1.0);
255        assert!((spring.value(spring.settle_time()) - 1.0).abs() < 0.01);
256    }
257
258    #[test]
259    fn settle_time_is_bounded_for_a_nearly_static_spring() {
260        assert_eq!(Spring::new(1.0, 1000.0, 50.0).settle_time(), MAX_SETTLE);
261    }
262
263    /// The three damping regimes, so a claim about the solver is a claim about
264    /// all of it.
265    fn regimes() -> [Spring; 3] {
266        [
267            spring(SpringPreset::Bouncy),
268            Spring::new(400.0, 40.0, 1.0),
269            Spring::new(200.0, 80.0, 1.0),
270        ]
271    }
272
273    #[test]
274    fn released_from_rest_the_general_solution_is_the_step_response() {
275        for spring in regimes() {
276            for step in 0..200 {
277                let elapsed = Duration::from_millis(step * 5);
278                let (value, _) = spring.value_at(elapsed, 0.0);
279                assert!(
280                    (value - spring.value(elapsed)).abs() < 1e-4,
281                    "{spring:?} diverged at {elapsed:?}: {value}"
282                );
283            }
284        }
285    }
286
287    #[test]
288    fn a_carried_velocity_is_the_starting_velocity() {
289        for spring in regimes() {
290            let (value, velocity) = spring.value_at(Duration::ZERO, 3.0);
291            assert_eq!(value, 0.0);
292            assert!((velocity - 3.0).abs() < 1e-5, "{spring:?} lost its speed");
293        }
294    }
295
296    #[test]
297    fn a_spring_released_with_speed_is_further_along_at_once() {
298        for spring in regimes() {
299            let early = Duration::from_millis(10);
300            assert!(
301                spring.value_at(early, 4.0).0 > spring.value(early),
302                "{spring:?} did not carry its velocity"
303            );
304        }
305    }
306
307    #[test]
308    fn every_regime_still_settles_from_a_carried_velocity() {
309        for spring in regimes() {
310            for velocity in [-4.0, 0.0, 6.0] {
311                let settle = spring.settle_time_at(velocity);
312                assert!(settle <= MAX_SETTLE);
313                let settled = spring.value_at(settle, velocity).0;
314                assert!(
315                    (settled - 1.0).abs() < 0.01,
316                    "{spring:?} at {velocity} settled on {settled}"
317                );
318            }
319        }
320    }
321
322    fn peak(spring: Spring) -> f32 {
323        let settle = spring.settle_time();
324        (0..=400)
325            .map(|step| spring.value(settle.mul_f32(step as f32 / 400.0)))
326            .fold(f32::MIN, f32::max)
327    }
328
329    #[test]
330    fn a_bounce_of_zero_is_critical_damping() {
331        let spring = Spring::perceptual(Duration::from_millis(400), 0.0);
332        assert!((spring.damping_ratio() - 1.0).abs() < 1e-4);
333        assert!(peak(spring) <= 1.0 + SETTLE_EPSILON, "it passed its target");
334    }
335
336    #[test]
337    fn only_a_positive_bounce_overshoots() {
338        let duration = Duration::from_millis(400);
339        let bouncy = Spring::perceptual(duration, 0.4);
340        let sluggish = Spring::perceptual(duration, -0.4);
341        assert!((bouncy.damping_ratio() - 0.6).abs() < 1e-4);
342        assert!((sluggish.damping_ratio() - 1.0 / 0.6).abs() < 1e-4);
343        assert!(peak(bouncy) > 1.0, "a positive bounce passes its target");
344        assert!(
345            peak(sluggish) <= 1.0 + SETTLE_EPSILON,
346            "a negative bounce must only ever approach it"
347        );
348    }
349
350    #[test]
351    fn a_perceptual_spring_is_nearly_arrived_at_the_duration_it_was_given() {
352        for ms in [150, 400, 900] {
353            let duration = Duration::from_millis(ms);
354            for bounce in [0.0, 0.3, 0.6] {
355                let spring = Spring::perceptual(duration, bounce);
356                let arrived = spring.value(duration);
357                // A bouncier spring is still visibly moving at its duration —
358                // that is what the bounce bought — so the tolerance covers the
359                // oscillation left at one period rather than the arrival.
360                assert!(
361                    (arrived - 1.0).abs() < 0.1,
362                    "{ms}ms at bounce {bounce} was {arrived} of the way there"
363                );
364            }
365        }
366    }
367
368    #[test]
369    fn a_negative_bounce_buys_its_calm_with_time() {
370        // An overdamped spring keeps the pace it was asked for but no longer
371        // lands on it: the duration is the frequency, not the arrival.
372        let duration = Duration::from_millis(400);
373        let arrived = |bounce| Spring::perceptual(duration, bounce).value(duration);
374        assert!(arrived(-0.2) < arrived(0.0));
375        assert!(arrived(-0.5) < arrived(-0.2));
376        assert!(arrived(-0.5) > 0.7, "it is still most of the way there");
377    }
378
379    #[test]
380    fn duration_and_bounce_survive_the_round_trip() {
381        for ms in [120, 350, 1000] {
382            for bounce in [-0.6, -0.2, 0.0, 0.25, 0.75] {
383                let asked = Duration::from_millis(ms);
384                let spring = Spring::perceptual(asked, bounce);
385                let read = spring.perceptual_duration();
386                assert!(
387                    read.abs_diff(asked) < Duration::from_millis(1),
388                    "{asked:?} at bounce {bounce} came back as {read:?}"
389                );
390                assert!(
391                    (spring.bounce() - bounce).abs() < 1e-3,
392                    "bounce {bounce} came back as {}",
393                    spring.bounce()
394                );
395            }
396        }
397    }
398
399    #[test]
400    fn a_longer_perceptual_duration_is_a_proportionally_longer_spring() {
401        let short = Spring::perceptual(Duration::from_millis(200), 0.2);
402        let long = Spring::perceptual(Duration::from_millis(400), 0.2);
403        let ratio = long.settle_time().as_secs_f32() / short.settle_time().as_secs_f32();
404        assert!(
405            (ratio - 2.0).abs() < 0.05,
406            "twice the duration settled in {ratio} times the time"
407        );
408    }
409
410    #[test]
411    fn a_bounce_past_the_limit_is_held_at_it() {
412        let duration = Duration::from_millis(300);
413        for bounce in [-4.0, 4.0] {
414            let spring = Spring::perceptual(duration, bounce);
415            assert!(
416                (spring.bounce().abs() - BOUNCE_LIMIT).abs() < 1e-3,
417                "bounce {bounce} became {}",
418                spring.bounce()
419            );
420            assert!(
421                spring.damping_ratio() > 0.0,
422                "bounce {bounce} lost its damping"
423            );
424        }
425        // Both ends are still solvable, whatever they do to the settle time.
426        assert!(
427            Spring::perceptual(duration, 4.0)
428                .value(duration)
429                .is_finite()
430        );
431        assert!(
432            Spring::perceptual(duration, -4.0)
433                .value(duration)
434                .is_finite()
435        );
436    }
437
438    #[test]
439    fn the_token_presets_are_untouched_by_the_perceptual_way_in() {
440        let tokens = Theme::studio_dark().spring(SpringPreset::Smooth);
441        let preset = spring(SpringPreset::Smooth);
442        assert_eq!(
443            preset,
444            Spring::new(tokens.stiffness, tokens.damping, tokens.mass)
445        );
446        assert_eq!(preset.stiffness, 180.0);
447        assert_eq!(preset.damping, 26.0);
448        assert_eq!(preset.mass, 1.0);
449    }
450
451    #[test]
452    fn a_spring_thrown_the_wrong_way_takes_longer_to_come_to_rest() {
453        let spring = spring(SpringPreset::Smooth);
454        assert!(spring.settle_time_at(-6.0) > spring.settle_time());
455    }
456}