solium_animation/lib.rs
1//! Solium's animation engine.
2//!
3//! Separated from the compositor on purpose, and the separation is load-bearing
4//! rather than tidy: **an animation you can only judge by launching a
5//! compositor is an animation nobody tunes.** Everything here is arithmetic on
6//! `f64` and `Duration`, so the same code that moves windows can be driven by a
7//! test, plotted by the preview tool in `preview/`, or embedded in whatever
8//! else wants to show what a curve does.
9//!
10//! There will be many animations and many settings for them. That is the reason
11//! for this shape: adding a curve here makes it available to every mode, every
12//! script and the preview, without touching the renderer.
13//!
14//! ```
15//! use std::time::Duration;
16//! use solium_animation::{Animation, Curve};
17//!
18//! let move_window = Animation::new(Duration::ZERO, Duration::from_millis(200), Curve::OutCubic);
19//! assert_eq!(move_window.progress(Duration::ZERO), 0.0);
20//! assert_eq!(move_window.progress(Duration::from_millis(200)), 1.0);
21//! ```
22
23use std::time::Duration;
24
25pub mod ffi;
26
27/// A damped spring.
28///
29/// Springs are described by how they *feel* — how stiff, how damped, how heavy
30/// — rather than by a duration, and they settle when they settle. That is why
31/// they are worth having next to the fixed-duration curves: a window flung by a
32/// gesture should carry the gesture's velocity into its animation, which no
33/// amount of easing curve can express.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct Spring {
36 /// How hard it pulls toward the target. Higher is faster and snappier.
37 pub stiffness: f64,
38 /// How much it resists moving. Higher settles sooner with less overshoot.
39 pub damping: f64,
40 /// How heavy the thing being moved is. Higher is slower and more sluggish.
41 pub mass: f64,
42 /// Velocity at the start, in fractions of the distance per second. A
43 /// gesture's throw speed goes here.
44 pub initial_velocity: f64,
45 /// How close counts as arrived.
46 pub epsilon: f64,
47}
48
49impl Default for Spring {
50 fn default() -> Self {
51 // Settles in a little under half a second with a small overshoot:
52 // quick enough to feel responsive, soft enough not to look mechanical.
53 // `the_default_spring_settles_in_a_little_under_half_a_second`.
54 Self {
55 stiffness: 300.0,
56 damping: 25.0,
57 mass: 1.0,
58 initial_velocity: 0.0,
59 epsilon: 0.001,
60 }
61 }
62}
63
64impl Spring {
65 /// The fraction of the way to the target at `seconds`.
66 ///
67 /// Solved in closed form rather than integrated step by step, so the value
68 /// depends only on the time asked for. A stepped simulation would give
69 /// different answers at different frame rates, which is precisely the bug
70 /// that makes an animation feel different on a 60 Hz screen than on a
71 /// 260 Hz one.
72 #[must_use]
73 pub fn value_at(&self, seconds: f64) -> f64 {
74 if seconds <= 0.0 {
75 return 0.0;
76 }
77 let (mass, stiffness) = (self.mass.max(f64::EPSILON), self.stiffness.max(0.0));
78 let natural = (stiffness / mass).sqrt();
79 if natural <= 0.0 {
80 return 1.0;
81 }
82 let zeta = self.damping / (2.0 * (stiffness * mass).sqrt());
83 let velocity = self.initial_velocity;
84
85 // Displacement from the target, starting at -1 and decaying to 0.
86 //
87 // Solving u(0) = -1, u'(0) = velocity gives the term `(zeta * natural
88 // - velocity)`. With a `+` there the sign of the initial velocity is
89 // inverted: a spring thrown *toward* its target starts out behind one
90 // thrown at nothing, which is backwards and is what a gesture would
91 // hand it.
92 let offset = if (zeta - 1.0).abs() < 1e-9 {
93 // Critically damped: the fastest approach with no overshoot.
94 -(1.0 + (natural - velocity) * seconds) * (-natural * seconds).exp()
95 } else if zeta < 1.0 {
96 // Underdamped: overshoots and rings back. What "bouncy" means.
97 let damped = natural * (1.0 - zeta * zeta).sqrt();
98 let decay = (-zeta * natural * seconds).exp();
99 -decay
100 * ((damped * seconds).cos()
101 + ((zeta * natural - velocity) / damped) * (damped * seconds).sin())
102 } else {
103 // Overdamped: crawls in without overshooting.
104 let rate = natural * (zeta * zeta - 1.0).sqrt();
105 let decay = (-zeta * natural * seconds).exp();
106 -decay
107 * ((rate * seconds).cosh()
108 + ((zeta * natural - velocity) / rate) * (rate * seconds).sinh())
109 };
110
111 1.0 + offset
112 }
113
114 /// Roughly when it stops moving, so a caller knows when to stop asking.
115 ///
116 /// Found by walking forward rather than solved, because the exact answer
117 /// needs the Lambert W function and this is only ever used to decide when
118 /// to drop an animation.
119 #[must_use]
120 pub fn settle_time(&self) -> Duration {
121 const STEP: f64 = 1.0 / 240.0;
122 const LIMIT: f64 = 10.0;
123
124 let mut seconds = 0.0;
125 while seconds < LIMIT {
126 seconds += STEP;
127 if (1.0 - self.value_at(seconds)).abs() <= self.epsilon
128 && (1.0 - self.value_at(seconds + STEP)).abs() <= self.epsilon
129 {
130 return Duration::from_secs_f64(seconds);
131 }
132 }
133 Duration::from_secs_f64(LIMIT)
134 }
135}
136
137/// How a value travels from one end to the other.
138#[derive(Clone, Copy, Debug, Default, PartialEq)]
139pub enum Curve {
140 Linear,
141 /// Fast start, soft landing. The default because it reads as responsive
142 /// without looking mechanical.
143 #[default]
144 OutCubic,
145 /// Overshoots slightly and settles back — for things appearing.
146 OutBack,
147 /// Slow at both ends. For things that move rather than appear.
148 InOutQuad,
149 /// Slow at both ends, more so: a longer wind-up and a longer settle around
150 /// a faster middle. The same journey [`Self::InOutQuad`] makes, read as
151 /// *weight* rather than as travel — for something crossing a screen rather
152 /// than moving between two slots, which is what the genie does.
153 InOutCubic,
154 Spring(Spring),
155 /// Any curve at all, as the two control points of a cubic bezier from
156 /// (0,0) to (1,1) -- the same four numbers CSS calls `cubic-bezier` and
157 /// every easing site on the internet hands out.
158 ///
159 /// The named curves above are the ones worth having a name; this is so a
160 /// feel nobody anticipated does not need a compositor release.
161 Bezier {
162 x1: f64,
163 y1: f64,
164 x2: f64,
165 y2: f64,
166 },
167}
168
169/// A cubic bezier from (0,0) to (1,1), evaluated at `t` along the *x* axis.
170///
171/// The curve is parametric, so a y for a given x needs the parameter that
172/// puts x there. Newton's method converges in a couple of steps for the
173/// well-behaved curves easings are, and bisection finishes the rest -- the
174/// same approach browsers take, for the same reason.
175fn bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
176 /// Close enough that a further step would move the result by less than a
177 /// pixel on any real screen.
178 const EPSILON: f64 = 1e-6;
179 const NEWTON_STEPS: u8 = 8;
180
181 let curve = |a: f64, b: f64, u: f64| {
182 // The Bernstein form, with the two ends pinned at 0 and 1.
183 let inverse = 1.0 - u;
184 3.0 * inverse * inverse * u * a + 3.0 * inverse * u * u * b + u * u * u
185 };
186 let slope = |a: f64, b: f64, u: f64| {
187 let inverse = 1.0 - u;
188 3.0 * inverse * inverse * a + 6.0 * inverse * u * (b - a) + 3.0 * u * u * (1.0 - b)
189 };
190
191 let mut guess = t;
192 for _ in 0..NEWTON_STEPS {
193 let error = curve(x1, x2, guess) - t;
194 if error.abs() < EPSILON {
195 return curve(y1, y2, guess);
196 }
197 let gradient = slope(x1, x2, guess);
198 // A flat stretch cannot be stepped along; bisection below handles it.
199 if gradient.abs() < EPSILON {
200 break;
201 }
202 guess -= error / gradient;
203 }
204
205 let (mut low, mut high) = (0.0, 1.0);
206 let mut guess = t.clamp(0.0, 1.0);
207 for _ in 0..32 {
208 let x = curve(x1, x2, guess);
209 if (x - t).abs() < EPSILON {
210 break;
211 }
212 if x < t {
213 low = guess;
214 } else {
215 high = guess;
216 }
217 guess = f64::midpoint(low, high);
218 }
219 curve(y1, y2, guess)
220}
221
222impl Curve {
223 /// The curve's value for a normalised time in `0..=1`.
224 ///
225 /// May exceed 1 on the way — that is what overshoot is — so callers must
226 /// not clamp the result, only the time.
227 #[must_use]
228 pub fn at(self, t: f64) -> f64 {
229 let t = t.clamp(0.0, 1.0);
230 match self {
231 Self::Linear => t,
232 Self::OutCubic => {
233 let inverse = 1.0 - t;
234 1.0 - inverse * inverse * inverse
235 }
236 Self::OutBack => {
237 const OVERSHOOT: f64 = 1.701_58;
238 let inverse = t - 1.0;
239 1.0 + (OVERSHOOT + 1.0) * inverse * inverse * inverse
240 + OVERSHOOT * inverse * inverse
241 }
242 Self::Bezier { x1, y1, x2, y2 } => bezier(t, x1, y1, x2, y2),
243 Self::InOutQuad => {
244 if t < 0.5 {
245 2.0 * t * t
246 } else {
247 let inverse = -2.0 * t + 2.0;
248 1.0 - (inverse * inverse) / 2.0
249 }
250 }
251 Self::InOutCubic => {
252 if t < 0.5 {
253 4.0 * t * t * t
254 } else {
255 let inverse = -2.0 * t + 2.0;
256 1.0 - (inverse * inverse * inverse) / 2.0
257 }
258 }
259 // A spring is described in seconds, not in fractions of a fixed
260 // duration, so a normalised time has to be given one: its own
261 // settling time.
262 Self::Spring(spring) => spring.value_at(t * spring.settle_time().as_secs_f64()),
263 }
264 }
265
266 /// Every curve, for tools that want to show them all.
267 ///
268 /// This is the whole vocabulary [`Self::from_name`] knows, so it is also
269 /// what a script may write — and what the shipped `lua/*.lua` is checked
270 /// against by `script::tests`. [`Self::Bezier`] is deliberately not here:
271 /// it has no name, it is four numbers.
272 #[must_use]
273 pub fn all() -> [(&'static str, Self); 6] {
274 [
275 ("linear", Self::Linear),
276 ("outCubic", Self::OutCubic),
277 ("outBack", Self::OutBack),
278 ("inOutQuad", Self::InOutQuad),
279 ("inOutCubic", Self::InOutCubic),
280 ("spring", Self::Spring(Spring::default())),
281 ]
282 }
283
284 /// Look a curve up by the name a script or a settings file would use.
285 #[must_use]
286 pub fn from_name(name: &str) -> Option<Self> {
287 let wanted = name.trim().to_ascii_lowercase().replace(['_', '-'], "");
288 Self::all()
289 .into_iter()
290 .find(|(known, _)| known.to_ascii_lowercase() == wanted)
291 .map(|(_, curve)| curve)
292 }
293
294 /// What to call this curve.
295 #[must_use]
296 pub fn name(self) -> &'static str {
297 match self {
298 Self::Linear => "linear",
299 Self::OutCubic => "outCubic",
300 Self::OutBack => "outBack",
301 Self::InOutQuad => "inOutQuad",
302 Self::InOutCubic => "inOutCubic",
303 Self::Spring(_) => "spring",
304 Self::Bezier { .. } => "bezier",
305 }
306 }
307}
308
309/// One animation: when it started, how long it takes, and how it moves.
310///
311/// Deliberately holds no value of its own. It reports *progress*, and the
312/// caller interpolates whatever it is animating — a rectangle, an opacity, a
313/// colour. That is what lets one engine drive window geometry in the compositor
314/// and a box in a preview page without knowing about either.
315#[derive(Clone, Copy, Debug, PartialEq)]
316pub struct Animation {
317 start: Duration,
318 duration: Duration,
319 curve: Curve,
320}
321
322impl Animation {
323 #[must_use]
324 pub fn new(start: Duration, duration: Duration, curve: Curve) -> Self {
325 // A spring's duration is a property of the spring, not a setting: it
326 // arrives when the physics says it does.
327 let duration = match curve {
328 Curve::Spring(spring) => spring.settle_time(),
329 _ => duration,
330 };
331 Self {
332 start,
333 duration,
334 curve,
335 }
336 }
337
338 #[must_use]
339 pub fn duration(&self) -> Duration {
340 self.duration
341 }
342
343 #[must_use]
344 pub fn curve(&self) -> Curve {
345 self.curve
346 }
347
348 /// How far along it is, which is not the same as how much time has passed.
349 #[must_use]
350 pub fn progress(&self, now: Duration) -> f64 {
351 if self.duration.is_zero() {
352 return 1.0;
353 }
354 let elapsed = now.saturating_sub(self.start).as_secs_f64();
355 self.curve.at(elapsed / self.duration.as_secs_f64())
356 }
357
358 #[must_use]
359 pub fn done(&self, now: Duration) -> bool {
360 now.saturating_sub(self.start) >= self.duration
361 }
362}
363
364/// Interpolate between two values.
365///
366/// Takes progress rather than time, so overshoot passes through: a curve that
367/// goes past 1 moves the value past its target and back, which is the whole
368/// point of `OutBack` and of an underdamped spring.
369#[must_use]
370pub fn lerp(from: f64, to: f64, progress: f64) -> f64 {
371 from + (to - from) * progress
372}
373
374#[cfg(test)]
375mod tests {
376 #[test]
377 fn a_bezier_hits_both_ends_and_stays_monotonic() {
378 // ease-in-out, as CSS spells it.
379 let curve = Curve::Bezier {
380 x1: 0.42,
381 y1: 0.0,
382 x2: 0.58,
383 y2: 1.0,
384 };
385 assert!(curve.at(0.0).abs() < 1e-6);
386 assert!((curve.at(1.0) - 1.0).abs() < 1e-6);
387 // Slow at the start, fast in the middle: half way through the time is
388 // half way along the distance, and the quarter point is behind it.
389 assert!((curve.at(0.5) - 0.5).abs() < 1e-6);
390 assert!(curve.at(0.25) < 0.25);
391 assert!(curve.at(0.75) > 0.75);
392 }
393
394 #[test]
395 fn a_bezier_can_overshoot() {
396 // The four numbers every "ease-out-back" generator produces.
397 let curve = Curve::Bezier {
398 x1: 0.34,
399 y1: 1.56,
400 x2: 0.64,
401 y2: 1.0,
402 };
403 let peak = (0..=100)
404 .map(|step| curve.at(f64::from(step) / 100.0))
405 .fold(f64::MIN, f64::max);
406 assert!(peak > 1.0, "an overshoot that does not exceed 1 is not one");
407 }
408
409 use super::*;
410
411 #[test]
412 fn every_curve_starts_at_zero_and_lands_on_one() {
413 for (name, curve) in Curve::all() {
414 assert!(curve.at(0.0).abs() < 1e-6, "{name} must start at 0");
415 assert!(
416 (curve.at(1.0) - 1.0).abs() < 1e-2,
417 "{name} must land on 1, got {}",
418 curve.at(1.0)
419 );
420 }
421 }
422
423 #[test]
424 fn time_is_clamped_but_value_is_not() {
425 // Past the end it stays landed rather than running away.
426 assert!((Curve::OutCubic.at(5.0) - 1.0).abs() < 1e-9);
427 assert_eq!(Curve::OutCubic.at(-1.0), 0.0);
428 // OutBack overshoots on the way, and that must survive.
429 let peak = (1..100)
430 .map(|step| Curve::OutBack.at(f64::from(step) / 100.0))
431 .fold(f64::MIN, f64::max);
432 assert!(peak > 1.0, "outBack must overshoot, peaked at {peak}");
433 }
434
435 #[test]
436 fn a_bouncy_spring_overshoots_and_a_stiff_one_does_not() {
437 let bouncy = Spring {
438 stiffness: 300.0,
439 damping: 10.0,
440 ..Spring::default()
441 };
442 let firm = Spring {
443 stiffness: 300.0,
444 damping: 40.0,
445 ..Spring::default()
446 };
447
448 let peak = |spring: Spring| {
449 (1..2000)
450 .map(|step| spring.value_at(f64::from(step) / 1000.0))
451 .fold(f64::MIN, f64::max)
452 };
453
454 assert!(peak(bouncy) > 1.02, "an underdamped spring rings past 1");
455 assert!(peak(firm) <= 1.0 + 1e-6, "an overdamped spring must not");
456 }
457
458 #[test]
459 fn a_spring_settles_and_says_when() {
460 let spring = Spring::default();
461 let settled = spring.settle_time();
462 assert!(
463 settled > Duration::from_millis(50) && settled < Duration::from_secs(3),
464 "implausible settling time: {settled:?}"
465 );
466 assert!((1.0 - spring.value_at(settled.as_secs_f64())).abs() <= spring.epsilon);
467 }
468
469 /// What `Spring::default` and `docs/animation.md` say of it: settled in
470 /// a little under half a second (445 ms), after a small overshoot (about
471 /// 4%).
472 #[test]
473 fn the_default_spring_settles_in_a_little_under_half_a_second() {
474 let spring = Spring::default();
475 let settled = spring.settle_time();
476 assert!(
477 settled > Duration::from_millis(400) && settled < Duration::from_millis(500),
478 "the default spring settled in {settled:?}"
479 );
480 let peak = (1..=500)
481 .map(|millis| spring.value_at(f64::from(millis) / 1000.0))
482 .fold(0.0_f64, f64::max);
483 assert!(
484 peak > 1.0 && peak < 1.05,
485 "the default spring's overshoot is not small: it peaks at {peak}"
486 );
487 }
488
489 #[test]
490 fn an_initial_velocity_throws_it_forward() {
491 let still = Spring::default();
492 let thrown = Spring {
493 initial_velocity: 5.0,
494 ..Spring::default()
495 };
496 assert!(
497 thrown.value_at(0.02) > still.value_at(0.02),
498 "a spring given velocity must start out ahead"
499 );
500 }
501
502 #[test]
503 fn a_springs_duration_comes_from_the_spring_not_the_caller() {
504 let spring = Spring::default();
505 let animation = Animation::new(
506 Duration::ZERO,
507 // Ignored: a spring arrives when the physics says it does.
508 Duration::from_secs(99),
509 Curve::Spring(spring),
510 );
511 assert_eq!(animation.duration(), spring.settle_time());
512 }
513
514 #[test]
515 fn curves_are_found_by_the_names_scripts_use() {
516 assert_eq!(Curve::from_name("outCubic"), Some(Curve::OutCubic));
517 assert_eq!(Curve::from_name("out_cubic"), Some(Curve::OutCubic));
518 assert_eq!(Curve::from_name("OUT-CUBIC"), Some(Curve::OutCubic));
519 assert_eq!(Curve::from_name("nonsense"), None);
520 }
521
522 /// Every name in [`Curve::all`] is one a script can write, and every curve
523 /// answers to the name it is listed under.
524 ///
525 /// The round trip rather than either half, because the halves are two
526 /// lists that have to agree and there is nothing but this holding them
527 /// together. A curve added to the enum and to `name` but not to `all` is
528 /// one the compositor will print and then refuse to read back — which is
529 /// the state `bezier` is in, deliberately: it has no name, it is four
530 /// numbers, and it is not in `all` for exactly that reason.
531 #[test]
532 fn every_name_resolves_and_every_curve_names_itself() {
533 for (name, curve) in Curve::all() {
534 assert_eq!(Curve::from_name(name), Some(curve), "{name} is unreadable");
535 assert_eq!(curve.name(), name, "{name} does not answer to its own name");
536 }
537 // The exception, written down so it reads as a decision.
538 assert_eq!(Curve::from_name("bezier"), None);
539 }
540
541 /// `inOutCubic` is a different curve from `inOutQuad` and not a synonym.
542 ///
543 /// Which is the whole reason it is worth a name of its own: it holds back
544 /// harder at both ends and crosses the middle faster, so the same distance
545 /// reads as a heavier object. Both are symmetric about the halfway point,
546 /// and that symmetry is what separates an in-out curve from an out one.
547 #[test]
548 fn in_out_cubic_eases_harder_than_in_out_quad() {
549 assert!(
550 Curve::InOutCubic.at(0.25) < Curve::InOutQuad.at(0.25),
551 "a cubic in-out must leave later"
552 );
553 assert!(
554 Curve::InOutCubic.at(0.75) > Curve::InOutQuad.at(0.75),
555 "and arrive later too, having crossed the middle faster"
556 );
557 for curve in [Curve::InOutQuad, Curve::InOutCubic] {
558 assert!((curve.at(0.5) - 0.5).abs() < 1e-9, "not symmetric");
559 for step in 0..=50_u32 {
560 let t = f64::from(step) / 100.0;
561 assert!(
562 (curve.at(t) + curve.at(1.0 - t) - 1.0).abs() < 1e-9,
563 "{} is not symmetric at {t}",
564 curve.name()
565 );
566 }
567 }
568 }
569
570 #[test]
571 fn a_zero_length_animation_is_immediately_finished() {
572 let animation = Animation::new(Duration::ZERO, Duration::ZERO, Curve::OutCubic);
573 assert_eq!(animation.progress(Duration::ZERO), 1.0);
574 assert!(animation.done(Duration::ZERO));
575 }
576
577 #[test]
578 fn lerp_carries_overshoot_through() {
579 assert!((lerp(0.0, 100.0, 0.5) - 50.0).abs() < 1e-9);
580 // Progress past 1 must move the value past its target, or overshoot
581 // would be silently flattened at the point it matters.
582 assert!((lerp(0.0, 100.0, 1.1) - 110.0).abs() < 1e-9);
583 }
584}