use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crate::anim::{Easing, Lerp, Tween};
use super::runtime::with_rt;
use super::scheduler::request_frame;
use super::scope::Scope;
use super::signal::Signal;
struct Flight<T: Lerp> {
tween: Tween<T>,
started: Option<Instant>,
target: T,
}
struct AnimState<T: Lerp> {
flight: Option<Flight<T>>,
task_live: bool,
}
pub fn animate<T>(cx: Scope, source: Signal<T>, easing: Easing, duration: Duration) -> Signal<T>
where
T: Lerp + PartialEq + 'static,
{
let out = cx.signal(source.get_untracked());
let state: Rc<RefCell<AnimState<T>>> = Rc::new(RefCell::new(AnimState {
flight: None,
task_live: false,
}));
cx.effect_labeled("reactive-animate", move || {
let target = source.get(); let current = out.get_untracked();
if target == current {
state.borrow_mut().flight = None;
return;
}
{
let mut st = state.borrow_mut();
st.flight = Some(Flight {
tween: Tween::new(current, target, duration).with_easing(easing),
started: None,
target,
});
if !st.task_live {
st.task_live = true;
let state_for_task = state.clone();
register_frame_task(move |now| {
let mut st = state_for_task.borrow_mut();
if !out.is_alive() {
st.flight = None;
st.task_live = false;
return false;
}
let Some(flight) = st.flight.as_mut() else {
st.task_live = false;
return false; };
let started = *flight.started.get_or_insert(now);
let elapsed = now.saturating_duration_since(started);
if flight.tween.is_finished(elapsed) {
let target = flight.target;
st.flight = None;
st.task_live = false;
drop(st); out.set_if_changed(target);
false } else {
let sample = flight.tween.sample(elapsed);
drop(st);
out.set_if_changed(sample);
request_frame(); true
}
});
}
}
request_frame();
});
out
}
pub(crate) fn register_frame_task(task: impl FnMut(Instant) -> bool + 'static) {
with_rt(|rt| rt.frame_tasks.push(Box::new(task)));
}
pub fn run_frame_tasks(now: Instant) -> usize {
let mut tasks = with_rt(|rt| std::mem::take(&mut rt.frame_tasks));
tasks.retain_mut(|task| task(now));
with_rt(|rt| {
let newly = std::mem::take(&mut rt.frame_tasks);
rt.frame_tasks = tasks;
rt.frame_tasks.extend(newly);
rt.frame_tasks.len()
})
}
pub fn frame_tasks_pending() -> usize {
with_rt(|rt| rt.frame_tasks.len())
}
pub fn after(delay: Duration, f: impl FnOnce() + 'static) {
let _ = arm_timer_at(arm_now() + delay, f);
request_frame();
}
pub(crate) fn arm_timer_at(deadline: Instant, f: impl FnOnce() + 'static) -> u64 {
with_rt(|rt| {
rt.next_timer_id += 1;
let id = rt.next_timer_id;
rt.timers.push(super::runtime::TimerEntry {
deadline,
id,
f: Box::new(f),
});
id
})
}
pub(crate) fn cancel_timer(id: u64) -> bool {
let removed: Option<super::runtime::TimerEntry> = with_rt(|rt| {
let at = rt.timers.iter().position(|e| e.id == id)?;
Some(rt.timers.swap_remove(at))
});
removed.is_some()
}
pub(crate) fn timer_fire_now() -> Option<Instant> {
with_rt(|rt| rt.timer_now)
}
pub fn set_loop_clock(now: Option<Instant>) {
super::runtime::write_clock_now(now);
}
pub(crate) fn arm_now() -> Instant {
with_rt(|rt| rt.timer_now.or(rt.clock_now)).unwrap_or_else(Instant::now)
}
pub fn next_timer_deadline() -> Option<Instant> {
with_rt(|rt| rt.timers.iter().map(|e| e.deadline).min())
}
pub fn run_due_timers(now: Instant) -> usize {
let due: Vec<Box<dyn FnOnce()>> = with_rt(|rt| {
let mut fired = Vec::new();
let mut i = 0;
while i < rt.timers.len() {
if rt.timers[i].deadline <= now {
fired.push(rt.timers.swap_remove(i).f);
} else {
i += 1;
}
}
rt.timer_now = Some(now);
fired
});
struct FireGuard;
impl Drop for FireGuard {
fn drop(&mut self) {
super::runtime::clear_timer_now();
}
}
let _guard = FireGuard;
let count = due.len();
for f in due {
f();
}
count
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reactive::{create_root, flush_effects, take_frame_request};
#[test]
fn follower_chases_source_and_settles() {
let (_root, ()) = create_root(|cx| {
let source = cx.signal(0.0f32);
let follower = animate(cx, source, Easing::Linear, Duration::from_millis(100));
assert_eq!(follower.get_untracked(), 0.0);
assert_eq!(frame_tasks_pending(), 0, "no change, no task");
source.set(10.0);
flush_effects();
assert_eq!(frame_tasks_pending(), 1, "transition in flight");
assert!(take_frame_request(), "a frame was requested");
let t0 = Instant::now();
run_frame_tasks(t0); assert_eq!(follower.get_untracked(), 0.0);
run_frame_tasks(t0 + Duration::from_millis(50));
let mid = follower.get_untracked();
assert!(mid > 3.0 && mid < 7.0, "midpoint-ish: {mid}");
assert!(take_frame_request(), "in-flight frames keep pacing");
let left = run_frame_tasks(t0 + Duration::from_millis(150));
assert_eq!(follower.get_untracked(), 10.0, "lands exactly on target");
assert_eq!(left, 0, "settled: task dropped, idle is idle again");
});
}
#[test]
fn retarget_mid_flight_starts_from_current_value() {
let (_root, ()) = create_root(|cx| {
let source = cx.signal(0.0f32);
let follower = animate(cx, source, Easing::Linear, Duration::from_millis(100));
source.set(10.0);
flush_effects();
let t0 = Instant::now();
run_frame_tasks(t0);
run_frame_tasks(t0 + Duration::from_millis(50)); let mid = follower.get_untracked();
source.set(0.0); flush_effects();
let t1 = t0 + Duration::from_millis(60);
run_frame_tasks(t1); run_frame_tasks(t1 + Duration::from_millis(100));
assert_eq!(follower.get_untracked(), 0.0);
assert!(mid > 0.0, "was mid-flight when retargeted");
assert_eq!(frame_tasks_pending(), 0);
});
}
#[test]
fn follower_scope_disposed_mid_flight_cancels_quietly() {
let (root, (source, child)) = create_root(|cx| {
let child = cx.child();
let source = child.signal(0.0f32);
let _follower = animate(child, source, Easing::Linear, Duration::from_millis(100));
(source, child)
});
source.set(10.0);
flush_effects();
assert_eq!(frame_tasks_pending(), 1, "flight is live");
child.dispose(); let left = run_frame_tasks(Instant::now());
assert_eq!(left, 0, "task cancelled quietly, no panic, no residue");
root.dispose();
}
#[test]
fn identical_target_never_registers_a_task() {
let (_root, ()) = create_root(|cx| {
let source = cx.signal(5i32);
let follower = animate(cx, source, Easing::Linear, Duration::from_millis(50));
source.set(5); flush_effects();
assert_eq!(frame_tasks_pending(), 0, "equal target: zero work");
assert_eq!(follower.get_untracked(), 5);
});
}
}