Skip to main content

telar_motion_core/
animated.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::Instant;
4
5use reactive_core::{ReadSignal, RwSignal, signal};
6
7use crate::curve::{Curve, Spring, Tween};
8use crate::lerp::Lerp;
9use crate::ticker::{self, Tickable};
10
11// Retargeting within this squared distance of the current goal is a no-op, so a segment re-running view() (which re-calls retarget with the same source) never perturbs an in-flight animation.
12const NOOP_EPS_SQ: f32 = 1e-12;
13// Spring integration sub-step; large frame gaps are split into steps this size for stability.
14const MAX_SUBSTEP: f32 = 1.0 / 240.0;
15// Upper bound on a single integrated frame; caps the jump after a long stall (paused window, breakpoint).
16const MAX_FRAME_DT: f32 = 0.1;
17// Lower bound on one. The registry is thread-global but driven per surface, so a multi-surface app ticks every animation once per surface per frame; at those microsecond gaps both integration steps round away in f32 and the animation freezes short of settling, pinning `has_active()` true for good.
18const MIN_FRAME_DT: f32 = 1.0 / 1000.0;
19// Spring settles once both squared displacement and squared velocity fall below these.
20const DISP_EPS_SQ: f32 = 1e-6;
21const VEL_EPS_SQ: f32 = 1e-6;
22const MIN_MASS: f32 = 1e-4;
23
24pub(crate) struct AnimInner<T: Lerp + 'static> {
25    signal: RwSignal<T>,
26    current: T,
27    target: T,
28    // Value-space velocity (component-wise); only used by springs.
29    velocity: T,
30    // Tween origin captured at retarget; the tween lerps start -> target.
31    start: T,
32    elapsed_secs: f32,
33    curve: Curve,
34    settled: bool,
35    // Timestamp of the last integration; None means the next tick only establishes t0.
36    last: Option<Instant>,
37}
38
39impl<T: Lerp + 'static> AnimInner<T> {
40    // Integrate to `now` scaled by `scale`; returns the new value to publish, or None if it did not change this tick.
41    fn integrate(&mut self, now: Instant, scale: f32) -> Option<T> {
42        if self.settled {
43            return None;
44        }
45        // scale <= 0 means reduced-motion "instant": jump to the target and settle (D5).
46        if scale <= 0.0 {
47            return Some(self.snap_to_target());
48        }
49        let last = match self.last {
50            Some(last) => last,
51            None => {
52                self.last = Some(now);
53                return None;
54            }
55        };
56        let dt = now.saturating_duration_since(last).as_secs_f32() * scale;
57        // Before `self.last` advances: a skipped step must leave the clock alone so its time is carried into the next one rather than discarded.
58        if dt < MIN_FRAME_DT {
59            return None;
60        }
61        self.last = Some(now);
62        match self.curve {
63            Curve::Tween(t) => self.step_tween(t, dt),
64            Curve::Spring(s) => self.step_spring(s, dt),
65        }
66    }
67
68    fn step_tween(&mut self, t: Tween, dt: f32) -> Option<T> {
69        self.elapsed_secs += dt;
70        let duration = t.duration.as_secs_f32();
71        if duration <= 0.0 || self.elapsed_secs >= duration {
72            return Some(self.snap_to_target());
73        }
74        let eased = t.easing.apply(self.elapsed_secs / duration);
75        self.current = self.start.lerp(&self.target, eased);
76        Some(self.current.clone())
77    }
78
79    fn step_spring(&mut self, s: Spring, dt: f32) -> Option<T> {
80        let dt = dt.min(MAX_FRAME_DT);
81        let steps = (dt / MAX_SUBSTEP).ceil().max(1.0) as u32;
82        let h = dt / steps as f32;
83        let mass = s.mass.max(MIN_MASS);
84        let before = self.current.clone();
85        for _ in 0..steps {
86            // Semi-implicit Euler in value space: a = (-k*(x - target) - c*v) / m, then v += a*h, x += v*h.
87            let displacement = self.current.sub(&self.target);
88            let force = displacement
89                .scale(-s.stiffness)
90                .sub(&self.velocity.scale(s.damping));
91            let accel = force.scale(1.0 / mass);
92            self.velocity = self.velocity.add(&accel.scale(h));
93            self.current = self.current.add(&self.velocity.scale(h));
94        }
95        let arrived = self.current.sub(&self.target).magnitude_sq() < DISP_EPS_SQ
96            && self.velocity.magnitude_sq() < VEL_EPS_SQ;
97        if arrived || self.value_is_frozen(&before) {
98            return Some(self.snap_to_target());
99        }
100        Some(self.current.clone())
101    }
102
103    // A frame that left the value bit-identical will never move it again — same state, same forces — so the animation is over. This is what guarantees one terminates at all: the epsilons above cannot, being absolute while the value is not, so a spring on screen coordinates (one f32 ULP is 2.4e-4 at x=2400) goes numerically dead while still short of `DISP_EPS_SQ` and ticks forever. Snapping is safe precisely because the step failed to round: what is left to travel is below what the value can represent.
104    fn value_is_frozen(&self, before: &T) -> bool {
105        self.current.sub(before).magnitude_sq() == 0.0
106    }
107
108    fn snap_to_target(&mut self) -> T {
109        self.current = self.target.clone();
110        self.velocity = T::zero();
111        self.settled = true;
112        self.current.clone()
113    }
114}
115
116impl<T: Lerp + 'static> Tickable for RefCell<AnimInner<T>> {
117    fn tick(&self, now: Instant, scale: f32) {
118        // The reactive `.set()` runs outside the RefCell borrow so an effect that re-reads or retargets this same animation cannot re-enter a live borrow.
119        let (signal, value) = {
120            let mut inner = self.borrow_mut();
121            let value = inner.integrate(now, scale);
122            (inner.signal.clone(), value)
123        };
124        if let Some(value) = value {
125            signal.set(value);
126        }
127    }
128
129    fn is_settled(&self) -> bool {
130        self.borrow().settled
131    }
132}
133
134/// A signal-backed value that chases a `target` over time under a [`Curve`], driven by the central ticker.
135pub struct Animated<T: Lerp + 'static> {
136    inner: Rc<RefCell<AnimInner<T>>>,
137    id: u64,
138}
139
140impl<T: Lerp + 'static> Clone for Animated<T> {
141    fn clone(&self) -> Self {
142        Animated {
143            inner: Rc::clone(&self.inner),
144            id: self.id,
145        }
146    }
147}
148
149impl<T: Lerp + 'static> Animated<T> {
150    /// Create an animation resting at `initial`. It registers with the ticker only once retargeted away from its current goal.
151    pub fn new(initial: T, curve: impl Into<Curve>) -> Self {
152        let signal = signal(initial.clone());
153        let inner = Rc::new(RefCell::new(AnimInner {
154            signal,
155            current: initial.clone(),
156            target: initial.clone(),
157            velocity: T::zero(),
158            start: initial,
159            elapsed_secs: 0.0,
160            curve: curve.into(),
161            settled: true,
162            last: None,
163        }));
164        Animated {
165            inner,
166            id: ticker::next_id(),
167        }
168    }
169
170    /// Aim at a new `target`. Springs keep position and velocity (interruptible); tweens restart from the current value over the full duration. Retargeting to the current goal is a no-op.
171    pub fn retarget(&self, target: T) {
172        {
173            let mut inner = self.inner.borrow_mut();
174            if target.sub(&inner.target).magnitude_sq() <= NOOP_EPS_SQ {
175                return;
176            }
177            match inner.curve {
178                Curve::Spring(_) => {
179                    inner.target = target;
180                }
181                Curve::Tween(_) => {
182                    inner.start = inner.current.clone();
183                    inner.elapsed_secs = 0.0;
184                    inner.target = target;
185                }
186            }
187            inner.settled = false;
188            // Re-establish t0 on the next tick so a gap since the last activity does not integrate as one huge step.
189            inner.last = None;
190        }
191        // Bind the concrete Weak first so it unsize-coerces to Weak<dyn Tickable> at the call.
192        let weak = Rc::downgrade(&self.inner);
193        ticker::register(self.id, weak);
194    }
195
196    /// Reactive read: subscribes the calling segment to the animated value.
197    pub fn get(&self) -> T {
198        self.inner.borrow().signal.get()
199    }
200
201    /// A read-only handle to the underlying signal.
202    pub fn read(&self) -> ReadSignal<T> {
203        self.inner.borrow().signal.read_only()
204    }
205
206    /// Whether the animation has reached its target and deregistered.
207    pub fn is_settled(&self) -> bool {
208        self.inner.borrow().settled
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use std::time::Duration;
215
216    use super::*;
217    use crate::curve::{spring, tween};
218    use crate::easing::Easing;
219    use crate::ticker::{has_active, reset, set_scale, tick};
220    use geometry_core::Rect;
221
222    // Each test isolates the thread-local ticker state; libtest runs one thread per test but this is defensive against thread reuse.
223    fn fresh() -> Instant {
224        reset();
225        set_scale(1.0);
226        Instant::now()
227    }
228
229    #[test]
230    fn tween_reaches_eased_value_at_half_duration() {
231        let base = fresh();
232        let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::EaseInOut));
233        a.retarget(1.0);
234        tick(base);
235        tick(base + Duration::from_millis(100));
236        let expected = 0.0f32.lerp(&1.0, Easing::EaseInOut.apply(0.5));
237        assert!(
238            (a.get() - expected).abs() < 1e-4,
239            "{} != {expected}",
240            a.get()
241        );
242    }
243
244    #[test]
245    fn tween_settles_at_target_and_goes_inactive() {
246        let base = fresh();
247        let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
248        a.retarget(1.0);
249        tick(base);
250        tick(base + Duration::from_millis(200));
251        assert!((a.get() - 1.0).abs() < 1e-6);
252        assert!(a.is_settled());
253        assert!(!has_active());
254    }
255
256    #[test]
257    fn first_tick_does_not_move_the_value() {
258        let base = fresh();
259        let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
260        a.retarget(1.0);
261        // The establishing tick only records t0; nothing is set yet.
262        tick(base);
263        assert_eq!(a.get(), 0.0);
264        assert!(has_active());
265    }
266
267    #[test]
268    fn spring_settles_at_target() {
269        let base = fresh();
270        let a = Animated::new(0.0f32, spring(120.0, 22.0));
271        a.retarget(1.0);
272        tick(base);
273        let mut now = base;
274        for _ in 0..1000 {
275            now += Duration::from_millis(16);
276            tick(now);
277            if !has_active() {
278                break;
279            }
280        }
281        assert!(!has_active(), "spring never settled");
282        assert!((a.get() - 1.0).abs() < 1e-2, "settled at {}", a.get());
283    }
284
285    #[test]
286    fn spring_preserves_velocity_across_retarget() {
287        let base = fresh();
288        // Underdamped so it builds clear upward velocity early in the flight.
289        let a = Animated::new(0.0f32, spring(120.0, 14.0));
290        a.retarget(1.0);
291        tick(base);
292        tick(base + Duration::from_millis(16));
293        tick(base + Duration::from_millis(32));
294        let value_before = a.get();
295        // Retarget to the current value: with zero displacement, only preserved velocity can still move it.
296        a.retarget(value_before);
297        tick(base + Duration::from_millis(33));
298        tick(base + Duration::from_millis(37));
299        assert!(
300            a.get() > value_before,
301            "momentum lost: {} !> {value_before}",
302            a.get()
303        );
304    }
305
306    #[test]
307    fn retarget_to_current_goal_is_a_noop() {
308        let _ = fresh();
309        let a = Animated::new(5.0f32, tween(Duration::from_millis(200), Easing::Linear));
310        a.retarget(5.0);
311        assert!(a.is_settled());
312        assert!(!has_active());
313    }
314
315    #[test]
316    fn same_target_retarget_does_not_restart_the_tween() {
317        let base = fresh();
318        let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
319        a.retarget(1.0);
320        tick(base);
321        tick(base + Duration::from_millis(100));
322        assert!((a.get() - 0.5).abs() < 1e-4);
323        // A no-op retarget must not reset the timeline; progress continues.
324        a.retarget(1.0);
325        tick(base + Duration::from_millis(150));
326        assert!((a.get() - 0.75).abs() < 1e-4, "restarted: {}", a.get());
327    }
328
329    #[test]
330    fn scale_zero_jumps_straight_to_target() {
331        let base = fresh();
332        set_scale(0.0);
333        let a = Animated::new(0.0f32, spring(120.0, 14.0));
334        a.retarget(1.0);
335        tick(base);
336        assert_eq!(a.get(), 1.0);
337        assert!(a.is_settled());
338        assert!(!has_active());
339        set_scale(1.0);
340    }
341
342    // The magnitude is the point: at these coordinates the settle epsilons are below one f32 ULP.
343    #[test]
344    fn spring_on_large_coordinates_stops_being_active() {
345        let base = fresh();
346        let a = Animated::new(Rect::new(1920.0, 1080.0, 240.0, 64.0), spring(180.0, 26.0));
347        a.retarget(Rect::new(2400.0, 1080.0, 240.0, 64.0));
348        tick(base);
349        let mut now = base;
350        for _ in 0..600 {
351            now += Duration::from_micros(16_667);
352            tick(now);
353            if !has_active() {
354                break;
355            }
356        }
357        assert!(!has_active(), "spring never deregistered: {:?}", a.get());
358        assert!(
359            (a.get().x - 2400.0).abs() < 1e-2,
360            "settled at {:?}",
361            a.get()
362        );
363    }
364
365    // The tick pattern a multi-surface app produces: one real frame step, then one per sibling surface microseconds behind it.
366    #[test]
367    fn sub_frame_ticks_do_not_consume_elapsed_time() {
368        let base = fresh();
369        let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
370        a.retarget(1.0);
371        tick(base);
372        for extra in 1..7u64 {
373            tick(base + Duration::from_micros(extra * 20));
374        }
375        assert_eq!(a.get(), 0.0, "a sub-frame tick moved the value");
376        tick(base + Duration::from_millis(100));
377        assert!(
378            (a.get() - 0.5).abs() < 1e-3,
379            "elapsed time was lost to the sub-frame ticks: {}",
380            a.get()
381        );
382    }
383
384    #[test]
385    fn dropped_animation_deregisters() {
386        let base = fresh();
387        let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
388        a.retarget(1.0);
389        tick(base);
390        assert!(has_active());
391        drop(a);
392        assert!(!has_active());
393    }
394}