1use std::time::Duration;
8
9use gpui::Animation;
10use gpui_kit_theme::{SpringPreset, SpringTokens, Theme};
11
12const SETTLE_EPSILON: f32 = 0.001;
14const MAX_SETTLE: Duration = Duration::from_secs(4);
17const BOUNCE_LIMIT: f32 = 0.99;
21
22#[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 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 fn omega(self) -> f32 {
86 (self.stiffness / self.mass).sqrt()
87 }
88
89 pub fn damping_ratio(self) -> f32 {
91 self.damping / (2.0 * (self.stiffness * self.mass).sqrt())
92 }
93
94 pub fn perceptual_duration(self) -> Duration {
101 Duration::from_secs_f32(std::f32::consts::TAU / self.omega())
102 }
103
104 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 pub fn value(self, elapsed: Duration) -> f32 {
116 self.value_at(elapsed, 0.0).0
117 }
118
119 pub fn value_at(self, elapsed: Duration, velocity: f32) -> (f32, f32) {
127 let (error, error_rate) = self.error(elapsed, 1.0, -velocity);
130 (1.0 - error, -error_rate)
131 }
132
133 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 pub fn settle_time(self) -> Duration {
172 self.settle_time_at(0.0)
173 }
174
175 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 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 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 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 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 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}