Skip to main content

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}