git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit498c7403a3faba7bf09a79e8af29758f7d586fe9
parent7df9400b7d
authorLucas Galante <[email protected]>
date2026-07-07 22:04
feat(scene): Animated<T> animation primitive - tween + spring + easing (Phase 4)

scene::anim: an Animated<T> value that eases or springs toward a target over time, over an Animatable trait (impl'd for f32 and [f32;4] colors). Motion is Tween { duration, easing } (Linear/EaseIn/EaseOut/EaseInOutCubic) or Spring { stiffness, damping } via semi-implicit Euler; tick(dt) advances and returns whether still animating (the frame-loop keep-alive signal), set_target retargets (tween restarts from current, spring keeps velocity), jump_to snaps. Replaces the toolkit's instant boolean flips and the copy-pasted hover_animation. 10 headless tests; 136 total. Not yet wired into widgets/loop (runtime-gated follow-up).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

 src/scene/anim.rs | 334 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/scene/mod.rs  |   1 +
 2 files changed, 335 insertions(+)

diff --git a/src/scene/anim.rs b/src/scene/anim.rs
new file mode 100644
index 0000000..9399633
--- /dev/null
+++ b/src/scene/anim.rs
@@ -0,0 +1,334 @@
+//! Animation primitive — Phase 4 of the core rebuild.
+//!
+//! The legacy toolkit has almost no animation: a single hand-rolled `hover_animation` helper (with
+//! a dead duplicate), and everything else is an instant boolean flip (`hovered`, `pressed`,
+//! `network_opacity` as a static multiplier). This module provides the missing spine — a small
+//! [`Animated<T>`] value that eases or springs toward a target over time — so hover/press/opacity
+//! and transitions become interpolated instead of instantaneous.
+//!
+//! It is pure time-based math over an [`Animatable`] value, fully unit-testable without a clock,
+//! GPU, or event loop: drive it with [`Animated::tick`] and read [`Animated::value`]. Wiring it
+//! into widgets and having the frame loop keep ticking while anything is live is the (runtime-gated)
+//! follow-up; the loop already returns "still animating" from `tick`, which this feeds.
+
+/// A value that can be interpolated and integrated for animation (scalars, colors, points).
+pub trait Animatable: Copy {
+    fn lerp(self, other: Self, t: f32) -> Self;
+    fn add(self, other: Self) -> Self;
+    fn sub(self, other: Self) -> Self;
+    fn scale(self, s: f32) -> Self;
+    fn zero() -> Self;
+    /// Rough magnitude used for settle detection (need not be a true norm).
+    fn magnitude(self) -> f32;
+}
+
+impl Animatable for f32 {
+    fn lerp(self, other: Self, t: f32) -> Self {
+        self + (other - self) * t
+    }
+    fn add(self, other: Self) -> Self {
+        self + other
+    }
+    fn sub(self, other: Self) -> Self {
+        self - other
+    }
+    fn scale(self, s: f32) -> Self {
+        self * s
+    }
+    fn zero() -> Self {
+        0.0
+    }
+    fn magnitude(self) -> f32 {
+        self.abs()
+    }
+}
+
+impl Animatable for [f32; 4] {
+    fn lerp(self, other: Self, t: f32) -> Self {
+        [
+            self[0] + (other[0] - self[0]) * t,
+            self[1] + (other[1] - self[1]) * t,
+            self[2] + (other[2] - self[2]) * t,
+            self[3] + (other[3] - self[3]) * t,
+        ]
+    }
+    fn add(self, o: Self) -> Self {
+        [self[0] + o[0], self[1] + o[1], self[2] + o[2], self[3] + o[3]]
+    }
+    fn sub(self, o: Self) -> Self {
+        [self[0] - o[0], self[1] - o[1], self[2] - o[2], self[3] - o[3]]
+    }
+    fn scale(self, s: f32) -> Self {
+        [self[0] * s, self[1] * s, self[2] * s, self[3] * s]
+    }
+    fn zero() -> Self {
+        [0.0; 4]
+    }
+    fn magnitude(self) -> f32 {
+        self[0].abs().max(self[1].abs()).max(self[2].abs()).max(self[3].abs())
+    }
+}
+
+/// Easing curve applied to a tween's normalized time `t in [0,1]`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Easing {
+    Linear,
+    EaseInQuad,
+    EaseOutQuad,
+    EaseInOutCubic,
+}
+
+impl Easing {
+    pub fn apply(self, t: f32) -> f32 {
+        let t = t.clamp(0.0, 1.0);
+        match self {
+            Easing::Linear => t,
+            Easing::EaseInQuad => t * t,
+            Easing::EaseOutQuad => t * (2.0 - t),
+            Easing::EaseInOutCubic => {
+                if t < 0.5 {
+                    4.0 * t * t * t
+                } else {
+                    let f = -2.0 * t + 2.0;
+                    1.0 - f * f * f / 2.0
+                }
+            }
+        }
+    }
+}
+
+/// How an [`Animated`] value approaches its target.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Motion {
+    /// Ease from the value-at-retarget to the target over `duration` seconds.
+    Tween { duration: f32, easing: Easing },
+    /// Physical spring: `stiffness` pulls toward the target, `damping` bleeds velocity.
+    Spring { stiffness: f32, damping: f32 },
+}
+
+/// Below this (in `Animatable::magnitude`) a spring is considered settled.
+const SETTLE_EPS: f32 = 0.001;
+
+/// A value that animates toward a target. Retarget with [`set_target`](Animated::set_target); each
+/// frame call [`tick`](Animated::tick) with the elapsed seconds and read [`value`](Animated::value).
+#[derive(Clone, Copy, Debug)]
+pub struct Animated<T: Animatable> {
+    current: T,
+    start: T,
+    target: T,
+    velocity: T,
+    elapsed: f32,
+    motion: Motion,
+    animating: bool,
+}
+
+impl<T: Animatable> Animated<T> {
+    pub fn new(value: T, motion: Motion) -> Self {
+        Animated {
+            current: value,
+            start: value,
+            target: value,
+            velocity: T::zero(),
+            elapsed: 0.0,
+            motion,
+            animating: false,
+        }
+    }
+
+    pub fn tween(value: T, duration: f32, easing: Easing) -> Self {
+        Self::new(value, Motion::Tween { duration, easing })
+    }
+
+    pub fn spring(value: T, stiffness: f32, damping: f32) -> Self {
+        Self::new(value, Motion::Spring { stiffness, damping })
+    }
+
+    pub fn value(&self) -> T {
+        self.current
+    }
+
+    pub fn target(&self) -> T {
+        self.target
+    }
+
+    pub fn is_animating(&self) -> bool {
+        self.animating
+    }
+
+    /// Aim at a new target and start animating (unless already there). A tween restarts from the
+    /// current value; a spring keeps its velocity for continuous motion.
+    pub fn set_target(&mut self, target: T) {
+        if target.sub(self.current).magnitude() < SETTLE_EPS
+            && self.velocity.magnitude() < SETTLE_EPS
+        {
+            self.jump_to(target);
+            return;
+        }
+        self.target = target;
+        self.start = self.current;
+        self.elapsed = 0.0;
+        self.animating = true;
+    }
+
+    /// Snap instantly to `value`, cancelling any in-flight animation.
+    pub fn jump_to(&mut self, value: T) {
+        self.current = value;
+        self.start = value;
+        self.target = value;
+        self.velocity = T::zero();
+        self.elapsed = 0.0;
+        self.animating = false;
+    }
+
+    /// Advance by `dt` seconds. Returns whether the value is still animating (the signal the frame
+    /// loop uses to keep requesting frames).
+    pub fn tick(&mut self, dt: f32) -> bool {
+        if !self.animating {
+            return false;
+        }
+        match self.motion {
+            Motion::Tween { duration, easing } => {
+                self.elapsed += dt;
+                let t = if duration > 0.0 { (self.elapsed / duration).clamp(0.0, 1.0) } else { 1.0 };
+                self.current = self.start.lerp(self.target, easing.apply(t));
+                if t >= 1.0 {
+                    self.current = self.target;
+                    self.animating = false;
+                }
+            }
+            Motion::Spring { stiffness, damping } => {
+                // Semi-implicit Euler.
+                let disp = self.target.sub(self.current);
+                let force = disp.scale(stiffness).sub(self.velocity.scale(damping));
+                self.velocity = self.velocity.add(force.scale(dt));
+                self.current = self.current.add(self.velocity.scale(dt));
+                if disp.magnitude() < SETTLE_EPS && self.velocity.magnitude() < SETTLE_EPS {
+                    self.current = self.target;
+                    self.velocity = T::zero();
+                    self.animating = false;
+                }
+            }
+        }
+        self.animating
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn run_to_settle<T: Animatable>(a: &mut Animated<T>, dt: f32, max_steps: usize) -> usize {
+        let mut n = 0;
+        while a.tick(dt) && n < max_steps {
+            n += 1;
+        }
+        n
+    }
+
+    #[test]
+    fn new_is_idle_at_value() {
+        let a = Animated::tween(5.0f32, 0.3, Easing::Linear);
+        assert_eq!(a.value(), 5.0);
+        assert!(!a.is_animating());
+    }
+
+    #[test]
+    fn linear_tween_reaches_target_and_stops() {
+        let mut a = Animated::tween(0.0f32, 1.0, Easing::Linear);
+        a.set_target(10.0);
+        assert!(a.is_animating());
+        // Halfway: linear => 5.0.
+        a.tick(0.5);
+        assert!((a.value() - 5.0).abs() < 1e-4);
+        // Finish.
+        let still = a.tick(0.5);
+        assert!(!still);
+        assert_eq!(a.value(), 10.0);
+        assert!(!a.is_animating());
+    }
+
+    #[test]
+    fn tween_overshoot_dt_clamps_to_target() {
+        let mut a = Animated::tween(0.0f32, 0.2, Easing::Linear);
+        a.set_target(1.0);
+        assert!(!a.tick(10.0)); // dt far exceeds duration
+        assert_eq!(a.value(), 1.0);
+    }
+
+    #[test]
+    fn retarget_restarts_tween_from_current() {
+        let mut a = Animated::tween(0.0f32, 1.0, Easing::Linear);
+        a.set_target(10.0);
+        a.tick(0.5); // now at 5.0
+        a.set_target(0.0); // reverse
+        assert!((a.value() - 5.0).abs() < 1e-4, "keeps current value at retarget");
+        a.tick(0.5); // halfway back from 5 -> 0
+        assert!((a.value() - 2.5).abs() < 1e-4);
+    }
+
+    #[test]
+    fn easing_endpoints_and_shape() {
+        for e in [Easing::Linear, Easing::EaseInQuad, Easing::EaseOutQuad, Easing::EaseInOutCubic] {
+            assert!((e.apply(0.0) - 0.0).abs() < 1e-6, "{e:?} at 0");
+            assert!((e.apply(1.0) - 1.0).abs() < 1e-6, "{e:?} at 1");
+        }
+        // EaseInQuad starts slow: at t=0.5 it's below linear (0.25 < 0.5).
+        assert!(Easing::EaseInQuad.apply(0.5) < 0.5);
+        // EaseOutQuad starts fast: above linear at t=0.5.
+        assert!(Easing::EaseOutQuad.apply(0.5) > 0.5);
+    }
+
+    #[test]
+    fn spring_converges_and_settles() {
+        let mut a = Animated::spring(0.0f32, 120.0, 20.0);
+        a.set_target(1.0);
+        let steps = run_to_settle(&mut a, 1.0 / 60.0, 100_000);
+        assert!(!a.is_animating(), "spring settled within {steps} steps");
+        assert!((a.value() - 1.0).abs() < 0.01, "converged to target, got {}", a.value());
+        assert!(steps > 1, "took a few frames, not instant");
+    }
+
+    #[test]
+    fn spring_keeps_velocity_across_retarget() {
+        let mut a = Animated::spring(0.0f32, 100.0, 15.0);
+        a.set_target(1.0);
+        for _ in 0..5 {
+            a.tick(1.0 / 60.0);
+        }
+        let moving = a.value();
+        a.set_target(2.0);
+        // Still animating and continues past the intermediate value toward the new target.
+        assert!(a.is_animating());
+        let steps = run_to_settle(&mut a, 1.0 / 60.0, 100_000);
+        assert!((a.value() - 2.0).abs() < 0.01, "reached new target in {steps} steps from {moving}");
+    }
+
+    #[test]
+    fn jump_to_is_instant_and_idle() {
+        let mut a = Animated::tween(0.0f32, 1.0, Easing::Linear);
+        a.set_target(10.0);
+        a.tick(0.3);
+        a.jump_to(7.0);
+        assert_eq!(a.value(), 7.0);
+        assert!(!a.is_animating());
+        assert!(!a.tick(1.0), "no motion after jump");
+    }
+
+    #[test]
+    fn set_target_equal_to_current_does_not_animate() {
+        let mut a = Animated::tween(3.0f32, 1.0, Easing::Linear);
+        a.set_target(3.0);
+        assert!(!a.is_animating());
+    }
+
+    #[test]
+    fn animates_a_color_via_tween() {
+        let mut a = Animated::tween([0.0, 0.0, 0.0, 1.0], 1.0, Easing::Linear);
+        a.set_target([1.0, 0.5, 0.0, 1.0]);
+        a.tick(0.5);
+        let v = a.value();
+        assert!((v[0] - 0.5).abs() < 1e-4 && (v[1] - 0.25).abs() < 1e-4 && (v[2] - 0.0).abs() < 1e-4);
+        assert!(!a.tick(0.5));
+        assert_eq!(a.value(), [1.0, 0.5, 0.0, 1.0]);
+    }
+}
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 6bb6ca2..247ba1a 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -5,6 +5,7 @@
 //! node [`Arena`]. Later phases add the layout pass, the paint/display-list, and animation on
 //! top of the same node identity.
 
+pub mod anim;
 pub mod arena;
 pub mod bridge;
 pub mod layout;