Skip to main content

telar_motion_core/
ticker.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::mem::ManuallyDrop;
4use std::rc::Weak;
5use std::time::Instant;
6
7/// Registry-facing behavior of an animation, erased over its value type.
8pub(crate) trait Tickable {
9    fn tick(&self, now: Instant, scale: f32);
10    fn is_settled(&self) -> bool;
11}
12
13struct Registry {
14    // Weak so a dropped/unmounted `Animated` deregisters itself; keyed by animation id for idempotent re-registration.
15    entries: HashMap<u64, Weak<dyn Tickable>>,
16    next_id: u64,
17    // Global time scale (D5): 1.0 normal, 0.0 = jump instantly to targets, in-between = slow motion.
18    scale: f32,
19    // Live `Continuous` guards. Kept here rather than in their own registry because the runner asks the same question of both — "is anything still in flight?" — and this one already crosses the hot-reload FFI boundary that a second registry would have to duplicate.
20    continuous: u32,
21}
22
23impl Registry {
24    fn new() -> Self {
25        Registry {
26            entries: HashMap::new(),
27            next_id: 0,
28            scale: 1.0,
29            continuous: 0,
30        }
31    }
32}
33
34// ManuallyDrop keeps this TLS trivially-destructible: registering a TLS destructor from the app dylib would make dlclose unsafe during hot reload (mirrors reactive-core's runtime and rsx's hot_state). The map leaks per reload, which is fine for a dev-only path; `reset` clears it explicitly on teardown.
35thread_local! {
36    static REGISTRY: ManuallyDrop<RefCell<Registry>> = ManuallyDrop::new(RefCell::new(Registry::new()));
37}
38
39pub(crate) fn next_id() -> u64 {
40    REGISTRY.with(|r| {
41        let mut reg = r.borrow_mut();
42        let id = reg.next_id;
43        reg.next_id += 1;
44        id
45    })
46}
47
48pub(crate) fn register(id: u64, weak: Weak<dyn Tickable>) {
49    REGISTRY.with(|r| {
50        r.borrow_mut().entries.insert(id, weak);
51    });
52}
53
54/// A registered handle is live if it is still upgradeable and not yet settled.
55fn is_live(weak: &Weak<dyn Tickable>) -> bool {
56    matches!(weak.upgrade(), Some(anim) if !anim.is_settled())
57}
58
59/// Integrate every active animation to `now`, publishing changed values and deregistering settled ones.
60pub fn tick(now: Instant) {
61    // Snapshot live handles under a short borrow, then integrate without holding the registry borrow: each `.set()` may flush effects that re-enter the registry (register new animations).
62    let (scale, live): (f32, Vec<std::rc::Rc<dyn Tickable>>) = REGISTRY.with(|r| {
63        let reg = r.borrow();
64        let live = reg.entries.values().filter_map(Weak::upgrade).collect();
65        (reg.scale, live)
66    });
67    for anim in &live {
68        anim.tick(now, scale);
69    }
70    // Prune dead (dropped) and settled animations so has_active() returns false at rest.
71    REGISTRY.with(|r| {
72        r.borrow_mut().entries.retain(|_, weak| is_live(weak));
73    });
74}
75
76/// Whether any animation is still unsettled; the runner uses this to keep scheduling frames. Non-mutating: a Weak can go dead between `tick` and this call, so it re-tests liveness rather than trusting emptiness.
77pub fn has_active() -> bool {
78    REGISTRY.with(|r| r.borrow().entries.values().any(is_live))
79}
80
81/// Keeps frames coming while it lives, for content Telar cannot see changing.
82///
83/// An animation moves values Telar owns, so the tree reports itself dirty and the loop schedules the next
84/// frame on its own. A region filled from outside — a texture the application renders into
85/// (`telar::gpu::image`), a video decoding on another thread — changes no value here at all: the draw
86/// commands are identical every frame while the picture underneath is not. Nothing in the tree can
87/// notice that, which is why it has to be declared.
88///
89/// Hold one for as long as the region is on screen; dropping it lets the loop go back to sleep.
90pub struct Continuous(());
91
92impl Continuous {
93    pub fn new() -> Self {
94        REGISTRY.with(|r| r.borrow_mut().continuous += 1);
95        Self(())
96    }
97}
98
99impl Default for Continuous {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl Drop for Continuous {
106    fn drop(&mut self) {
107        REGISTRY.with(|r| {
108            let mut reg = r.borrow_mut();
109            reg.continuous = reg.continuous.saturating_sub(1);
110        });
111    }
112}
113
114/// Whether any [`Continuous`] region is alive. The runner keeps scheduling frames while it is true, and
115/// moves the content generation so the renderer cannot mistake identical commands for an identical frame.
116pub fn has_continuous() -> bool {
117    REGISTRY.with(|r| r.borrow().continuous > 0)
118}
119
120/// Drop all registered animations; parallels reactive `reset_runtime` on tree teardown / hot reload.
121pub fn reset() {
122    REGISTRY.with(|r| {
123        let mut reg = r.borrow_mut();
124        reg.entries.clear();
125        // The guards themselves live in the tree being torn down, and their `Drop` would saturate at zero against a counter this reset had already cleared. Clearing it here keeps a reload from leaving a phantom region scheduling frames forever.
126        reg.continuous = 0;
127    });
128}
129
130/// Set the global time scale (D5). 1.0 is normal; 0.0 makes animations jump instantly to their targets; values in between slow motion down. Negative inputs clamp to 0.0.
131pub fn set_scale(scale: f32) {
132    REGISTRY.with(|r| r.borrow_mut().scale = scale.max(0.0));
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    // The registry is thread-local and libtest reuses threads; each test starts from a known state.
140    fn fresh() {
141        reset();
142    }
143
144    #[test]
145    fn a_live_guard_keeps_the_loop_awake() {
146        fresh();
147        assert!(!has_continuous());
148        let region = Continuous::new();
149        assert!(has_continuous());
150        drop(region);
151        assert!(!has_continuous());
152    }
153
154    // Two viewports on screen at once: the loop sleeps when the last one goes, not the first.
155    #[test]
156    fn the_loop_sleeps_only_when_the_last_region_goes() {
157        fresh();
158        let first = Continuous::new();
159        let second = Continuous::new();
160        drop(first);
161        assert!(has_continuous(), "one region is still on screen");
162        drop(second);
163        assert!(!has_continuous());
164    }
165
166    // A guard lives in the tree a reload tears down, so its Drop runs against a counter already cleared. Saturating there rather than wrapping is what keeps a reload from leaving a phantom region behind.
167    #[test]
168    fn a_reload_leaves_no_phantom_region_scheduling_frames() {
169        fresh();
170        let region = Continuous::new();
171        reset();
172        assert!(!has_continuous());
173        drop(region);
174        assert!(!has_continuous(), "the counter must not wrap below zero");
175    }
176
177    // Animations settle and continuous regions do not; the runner asks the two questions separately because only the second one has to move the content generation.
178    #[test]
179    fn a_continuous_region_is_not_an_active_animation() {
180        fresh();
181        let _region = Continuous::new();
182        assert!(has_continuous());
183        assert!(!has_active(), "no animation was registered");
184    }
185}