use rustc_hash::FxHashMap;
use crate::key::Key;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Easing {
#[default]
EaseOut,
Linear,
EaseIn,
EaseInOut,
Spring,
Bouncy,
Smooth,
Snappy,
}
impl Easing {
pub const ALL: &'static [Easing] = &[
Easing::EaseOut,
Easing::Linear,
Easing::EaseIn,
Easing::EaseInOut,
Easing::Spring,
Easing::Bouncy,
Easing::Smooth,
Easing::Snappy,
];
pub fn name(self) -> &'static str {
match self {
Easing::EaseOut => "easeOut",
Easing::Linear => "linear",
Easing::EaseIn => "easeIn",
Easing::EaseInOut => "easeInOut",
Easing::Spring => "spring",
Easing::Bouncy => "bouncy",
Easing::Smooth => "smooth",
Easing::Snappy => "snappy",
}
}
pub fn from_index(i: usize) -> Easing {
Self::ALL.get(i).copied().unwrap_or_default()
}
pub fn bounce(self) -> Option<f32> {
match self {
Easing::Smooth => Some(0.0),
Easing::Snappy => Some(0.15),
Easing::Spring => Some(0.25),
Easing::Bouncy => Some(0.5),
Easing::EaseOut | Easing::Linear | Easing::EaseIn | Easing::EaseInOut => None,
}
}
pub fn is_spring(self) -> bool {
self.bounce().is_some()
}
pub fn apply(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match self {
Easing::Linear => t,
Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
Easing::EaseIn => t * t * t,
Easing::EaseInOut => {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
Easing::Spring | Easing::Bouncy | Easing::Smooth | Easing::Snappy => {
1.0 - (1.0 - t).powi(3)
}
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Repeat {
#[default]
Normal,
Reverse,
Alternate,
AlternateReverse,
}
impl Repeat {
pub const ALL: &'static [Repeat] = &[
Repeat::Normal,
Repeat::Reverse,
Repeat::Alternate,
Repeat::AlternateReverse,
];
pub fn name(self) -> &'static str {
match self {
Repeat::Normal => "normal",
Repeat::Reverse => "reverse",
Repeat::Alternate => "alternate",
Repeat::AlternateReverse => "alternateReverse",
}
}
pub fn from_index(i: usize) -> Repeat {
Self::ALL.get(i).copied().unwrap_or_default()
}
fn progress(self, u: f64) -> f32 {
let p = match self {
Repeat::Normal => u.rem_euclid(1.0),
Repeat::Reverse => 1.0 - u.rem_euclid(1.0),
Repeat::Alternate => {
let c = u.rem_euclid(2.0);
if c < 1.0 { c } else { 2.0 - c }
}
Repeat::AlternateReverse => {
let c = u.rem_euclid(2.0);
if c < 1.0 { 1.0 - c } else { c - 1.0 }
}
};
p as f32
}
}
const SPRING_STEP: f64 = 0.004;
const MAX_FRAME_DT: f64 = 0.1;
pub const MAX_BOUNCE: f32 = 0.9;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Bounce(std::num::NonZeroU16);
impl Bounce {
pub fn new(b: f32) -> Self {
let q = (b.clamp(0.0, MAX_BOUNCE) * 10_000.0).round() as u16;
Bounce(std::num::NonZeroU16::MIN.saturating_add(q))
}
pub fn get(self) -> f32 {
(self.0.get() - 1) as f32 / 10_000.0
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transition {
pub duration_ms: f32,
pub easing: Easing,
pub repeat: Repeat,
pub bounce: Option<Bounce>,
pub delay_ms: f32,
}
impl Transition {
pub fn ms(duration_ms: f32) -> Self {
Self {
duration_ms,
easing: Easing::EaseOut,
repeat: Repeat::Normal,
bounce: None,
delay_ms: 0.0,
}
}
pub fn easing(mut self, easing: Easing) -> Self {
self.easing = easing;
self
}
pub fn repeat(mut self, repeat: Repeat) -> Self {
self.repeat = repeat;
self
}
pub fn delay(mut self, delay_ms: f32) -> Self {
self.delay_ms = delay_ms;
self
}
pub fn bounce(mut self, bounce: f32) -> Self {
self.bounce = Some(Bounce::new(bounce));
self
}
pub fn curve(&self) -> Easing {
match self.bounce {
Some(_) if !self.easing.is_spring() => Easing::Spring,
_ => self.easing,
}
}
fn damping(&self) -> Option<f32> {
let own = self.curve().bounce()?;
Some(1.0 - self.bounce.map_or(own, Bounce::get))
}
}
pub(crate) type Track = [(f32, [f32; 4])];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum Slot {
Width = 0,
Height = 1,
Bg = 2,
Border = 3,
Radius = 4,
Pos = 5,
Opacity = 6,
Shadow = 7,
ShadowColor = 8,
}
const SLOTS: usize = 9;
impl Slot {
const ALL: [Slot; SLOTS] = [
Slot::Width,
Slot::Height,
Slot::Bg,
Slot::Border,
Slot::Radius,
Slot::Pos,
Slot::Opacity,
Slot::Shadow,
Slot::ShadowColor,
];
pub(crate) fn names(mask: u16) -> Vec<&'static str> {
Self::ALL
.into_iter()
.filter(|s| mask & (1 << *s as u16) != 0)
.map(Slot::name)
.collect()
}
pub(crate) fn name(self) -> &'static str {
match self {
Slot::Width => "width",
Slot::Height => "height",
Slot::Bg => "bg",
Slot::Border => "borderColor",
Slot::Radius => "radius",
Slot::Pos => "position",
Slot::Opacity => "opacity",
Slot::Shadow => "shadow",
Slot::ShadowColor => "shadowColor",
}
}
}
#[derive(Clone, Copy, Debug)]
struct Tween {
from: [f32; 4],
to: [f32; 4],
value: [f32; 4],
start: f64,
velocity: [f32; 4],
last_time: f64,
duration_ms: f32,
easing: Easing,
last_used: u64,
}
impl Tween {
fn settled(value: [f32; 4], start: f64, last_time: f64, t: Transition, frame_no: u64) -> Self {
Tween {
from: value,
to: value,
value,
start,
velocity: [0.0; 4],
last_time,
duration_ms: t.duration_ms,
easing: t.curve(),
last_used: frame_no,
}
}
fn leg(from: [f32; 4], to: [f32; 4], now: f64, t: Transition, frame_no: u64) -> Self {
Tween {
from,
to,
value: from,
start: now,
velocity: [0.0; 4],
last_time: now,
duration_ms: t.duration_ms,
easing: t.curve(),
last_used: frame_no,
}
}
#[inline]
fn progress_at(&self, now: f64) -> f32 {
let dur = self.duration_ms.max(0.0) as f64 / 1000.0;
if dur <= 0.0 {
1.0
} else {
(((now - self.start) / dur) as f32).clamp(0.0, 1.0)
}
}
#[inline]
fn at(&self, p: f32) -> [f32; 4] {
if p >= 1.0 {
return self.to;
}
let e = self.easing.apply(p);
std::array::from_fn(|i| self.from[i] + (self.to[i] - self.from[i]) * e)
}
fn eased_at(&self, now: f64) -> [f32; 4] {
self.at(self.progress_at(now))
}
fn spring_step(&mut self, now: f64, zeta: f32) -> bool {
let response = (self.duration_ms.max(1.0) / 1000.0) as f64;
let omega = std::f64::consts::TAU / response;
let k = (omega * omega) as f32;
let c = (2.0 * zeta as f64 * omega) as f32;
let dt = (now - self.last_time).clamp(0.0, MAX_FRAME_DT);
self.last_time = now;
let steps = (dt / SPRING_STEP).ceil().max(1.0);
let h = (dt / steps) as f32;
for _ in 0..steps as usize {
for i in 0..4 {
let a = -k * (self.value[i] - self.to[i]) - c * self.velocity[i];
self.velocity[i] += a * h;
self.value[i] += self.velocity[i] * h;
}
}
let moving = (0..4)
.any(|i| (self.value[i] - self.to[i]).abs() > 1e-3 || self.velocity[i].abs() > 5e-2);
if !moving {
self.value = self.to;
self.velocity = [0.0; 4];
}
moving
}
}
#[derive(Default)]
pub struct AnimStore {
tweens: FxHashMap<Key, [Option<Tween>; SLOTS]>,
now: Option<f64>,
frame_no: u64,
drove_at: Option<f64>,
owes_transition: bool,
owes_cycle: bool,
}
impl AnimStore {
pub fn set_time(&mut self, now_secs: f64) {
self.now = Some(now_secs);
}
pub fn time(&self) -> Option<f64> {
self.now
}
pub fn animating(&self) -> bool {
self.owes_transition || self.owes_cycle
}
pub fn owes(&self) -> (bool, bool) {
(self.owes_transition, self.owes_cycle)
}
pub(crate) fn begin_frame(&mut self, frame_no: u64) {
self.frame_no = frame_no;
self.drove_at = self.now;
self.owes_transition = false;
self.owes_cycle = false;
if !self.tweens.is_empty()
&& let Some(cutoff) = crate::retain::sweep_cutoff(self.frame_no)
{
self.tweens
.retain(|_, slots| slots.iter().flatten().any(|t| t.last_used >= cutoff));
}
}
pub(crate) fn owing(&self, mut each: impl FnMut(Key, Slot)) {
let Some(now) = self.drove_at.or(self.now) else {
return;
};
for (&key, slots) in &self.tweens {
for (i, tw) in slots.iter().enumerate() {
let Some(tw) = tw else { continue };
if tw.last_used != self.frame_no {
continue;
}
let moving = if tw.easing.is_spring() {
tw.value != tw.to || tw.velocity != [0.0; 4]
} else {
tw.progress_at(now) < 1.0
};
if moving {
each(key, Slot::ALL[i]);
}
}
}
}
pub(crate) fn drive(
&mut self,
key: Key,
slot: Slot,
enter_from: Option<[f32; 4]>,
target: [f32; 4],
transition: Transition,
follow: bool,
) -> [f32; 4] {
self.node(key)
.drive(slot, enter_from, target, transition, follow)
}
pub(crate) fn node(&mut self, key: Key) -> NodeAnim<'_> {
NodeAnim {
slots: self.tweens.entry(key).or_default(),
now: self.now,
frame_no: self.frame_no,
active: &mut self.owes_transition,
cycling: &mut self.owes_cycle,
}
}
}
fn sample_track(track: &Track, transition: Transition, now: Option<f64>) -> Option<[f32; 4]> {
let dur = transition.duration_ms as f64 / 1000.0;
let (Some(now), true, Some(&(_, first))) = (now, dur > 0.0, track.first()) else {
return None;
};
let u = (now - transition.delay_ms as f64 / 1000.0) / dur;
let p = transition.repeat.progress(u);
let mut from = (0.0, first);
for &(at, value) in track {
if p < at {
let (a, va) = from;
let t = if at > a { (p - a) / (at - a) } else { 1.0 };
let e = transition.curve().apply(t);
let mut out = [0.0; 4];
for i in 0..4 {
out[i] = va[i] + (value[i] - va[i]) * e;
}
return Some(out);
}
from = (at, value);
}
Some(from.1)
}
pub(crate) struct NodeAnim<'a> {
slots: &'a mut [Option<Tween>; SLOTS],
now: Option<f64>,
frame_no: u64,
active: &'a mut bool,
cycling: &'a mut bool,
}
impl NodeAnim<'_> {
pub(crate) fn sample(&mut self, track: &Track, transition: Transition) -> Option<[f32; 4]> {
let v = sample_track(track, transition, self.now);
if v.is_some() {
*self.cycling = true;
}
v
}
pub(crate) fn drive(
&mut self,
slot: Slot,
enter_from: Option<[f32; 4]>,
target: [f32; 4],
transition: Transition,
follow: bool,
) -> [f32; 4] {
let frame_no = self.frame_no;
let now = self.now;
let entry = &mut self.slots[slot as usize];
let stale = entry.is_none_or(|t| t.last_used + 1 < frame_no);
let Some(now) = now else {
*entry = Some(Tween::settled(target, 0.0, 0.0, transition, frame_no));
return target;
};
if stale {
match enter_from {
Some(from) if from != target => {
*entry = Some(Tween::leg(from, target, now, transition, frame_no));
}
_ => {
*entry = Some(Tween::settled(
target,
f64::NEG_INFINITY,
now,
transition,
frame_no,
));
return target;
}
}
}
let tw = entry.as_mut().expect("checked above");
tw.last_used = frame_no;
if !follow && tw.to != target && tw.value == tw.to && tw.velocity == [0.0; 4] {
tw.from = target;
tw.to = target;
tw.value = target;
tw.start = f64::NEG_INFINITY;
tw.last_time = now;
return target;
}
if let Some(zeta) = transition.damping() {
tw.to = target;
tw.duration_ms = transition.duration_ms;
tw.easing = transition.curve();
if tw.spring_step(now, zeta) {
*self.active = true;
}
return tw.value;
}
if tw.to != target {
tw.from = tw.eased_at(now);
tw.to = target;
tw.start = now;
tw.duration_ms = transition.duration_ms;
tw.easing = transition.curve();
}
let p = tw.progress_at(now);
if p < 1.0 {
*self.active = true;
}
tw.value = tw.at(p);
tw.value
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Frames(u64);
impl Frames {
fn next(&mut self) -> u64 {
self.0 += 1;
self.0
}
}
#[test]
fn a_tween_stays_small() {
const BOUND: usize = 96;
let size = std::mem::size_of::<Option<Tween>>();
assert!(
size <= BOUND,
"Option<Tween> is {size} bytes, over the {BOUND}-byte bound. \
It is held per slot per node across frames; put what the node \
owns on the node and what the cycle owns in the Transition."
);
assert_eq!(size, std::mem::size_of::<Tween>());
}
fn one(v: f32) -> [f32; 4] {
[v, 0.0, 0.0, 0.0]
}
#[test]
fn easings_hit_their_endpoints() {
for e in [
Easing::Linear,
Easing::EaseOut,
Easing::EaseIn,
Easing::EaseInOut,
Easing::Spring,
Easing::Bouncy,
Easing::Smooth,
Easing::Snappy,
] {
assert_eq!(e.apply(0.0), 0.0);
assert_eq!(e.apply(1.0), 1.0);
assert!(e.apply(0.5) > 0.0 && e.apply(0.5) < 1.0);
}
}
#[test]
fn first_sight_snaps_then_retargets_ease() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0).easing(Easing::Linear);
a.set_time(0.0);
a.begin_frame(frame.next());
assert_eq!(a.drive(k, Slot::Width, None, one(10.0), t, true)[0], 10.0);
assert!(!a.animating());
a.set_time(0.0);
a.begin_frame(frame.next());
assert_eq!(a.drive(k, Slot::Width, None, one(20.0), t, true)[0], 10.0);
assert!(a.animating(), "mid-flight after retarget");
a.set_time(0.05);
a.begin_frame(frame.next());
assert!((a.drive(k, Slot::Width, None, one(20.0), t, true)[0] - 15.0).abs() < 1e-4);
a.set_time(0.2);
a.begin_frame(frame.next());
assert_eq!(a.drive(k, Slot::Width, None, one(20.0), t, true)[0], 20.0);
assert!(!a.animating(), "settled");
}
#[test]
fn unchanged_targets_owe_no_frames() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0);
for i in 0..3 {
a.set_time(i as f64 * 0.001);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(5.0), t, true);
assert!(!a.animating(), "frame {i}: same value, nothing to animate");
}
}
#[test]
fn retarget_mid_flight_starts_from_current_value() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0).easing(Easing::Linear);
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(0.0), t, true);
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(100.0), t, true);
a.set_time(0.05);
a.begin_frame(frame.next());
assert!((a.drive(k, Slot::Width, None, one(100.0), t, true)[0] - 50.0).abs() < 1e-4);
a.set_time(0.05);
a.begin_frame(frame.next());
assert!((a.drive(k, Slot::Width, None, one(0.0), t, true)[0] - 50.0).abs() < 1e-4);
a.set_time(0.10);
a.begin_frame(frame.next());
assert!((a.drive(k, Slot::Width, None, one(0.0), t, true)[0] - 25.0).abs() < 1e-4);
}
#[test]
fn springs_overshoot_settle_and_keep_momentum() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(200.0).easing(Easing::Bouncy);
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(0.0), t, true);
let mut max = 0.0f32;
let mut settled_at = None;
for i in 1..=180 {
a.set_time(i as f64 / 60.0);
a.begin_frame(frame.next());
let v = a.drive(k, Slot::Width, None, one(100.0), t, true)[0];
max = max.max(v);
if !a.animating() && settled_at.is_none() {
settled_at = Some(i);
}
}
assert!(max > 101.0, "bouncy overshoots: peak {max}");
let settled = settled_at.expect("settles within 3s");
assert!(settled > 6, "not instant: {settled}");
a.set_time(4.0);
a.begin_frame(frame.next());
assert_eq!(a.drive(k, Slot::Width, None, one(100.0), t, true)[0], 100.0);
a.set_time(4.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(200.0), t, true);
let mut v_prev = 100.0;
for i in 1..=2 {
a.set_time(4.0 + i as f64 / 60.0);
a.begin_frame(frame.next());
v_prev = a.drive(k, Slot::Width, None, one(200.0), t, true)[0];
}
assert!(v_prev > 100.0);
a.set_time(4.0 + 3.0 / 60.0);
a.begin_frame(frame.next());
let after = a.drive(k, Slot::Width, None, one(100.0), t, true)[0];
assert!(
after > v_prev,
"momentum carries past the retarget: {v_prev} -> {after}"
);
}
fn peak(t: Transition) -> (f32, bool) {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(0.0), t, true);
let mut max = 0.0f32;
for i in 1..=180 {
a.set_time(i as f64 / 60.0);
a.begin_frame(frame.next());
max = max.max(a.drive(k, Slot::Width, None, one(100.0), t, true)[0]);
}
(max, !a.animating())
}
#[test]
fn a_spring_s_bounce_is_how_far_it_overshoots() {
let t = Transition::ms(200.0);
assert_eq!(t.easing(Easing::Spring).damping(), Some(0.75));
assert_eq!(t.easing(Easing::Bouncy).damping(), Some(0.5));
assert_eq!(t.damping(), None, "a timed curve is no spring");
let (smooth, settled) = peak(t.easing(Easing::Smooth));
assert!(settled, "smooth comes to rest");
assert!(smooth <= 100.0 + 1e-3, "smooth never overshoots: {smooth}");
let mut last = smooth;
for e in [Easing::Snappy, Easing::Spring, Easing::Bouncy] {
let (p, settled) = peak(t.easing(e));
assert!(settled, "{e:?} comes to rest");
assert!(
p > last,
"{e:?} overshoots past the one before: {p} <= {last}"
);
last = p;
}
assert_eq!(
peak(t.easing(Easing::Bouncy).bounce(0.0)).0,
smooth,
"bouncy with no bounce is smooth"
);
assert_eq!(
peak(t.easing(Easing::Smooth).bounce(0.5)).0,
last,
"smooth with bouncy's bounce is bouncy"
);
}
#[test]
fn a_bounce_on_a_timed_curve_makes_it_a_spring() {
let t = Transition::ms(200.0).easing(Easing::Linear).bounce(0.5);
assert_eq!(t.curve(), Easing::Spring);
assert_eq!(t.damping(), Some(0.5));
assert_eq!(
peak(t).0,
peak(Transition::ms(200.0).easing(Easing::Bouncy)).0
);
assert_eq!(Transition::ms(200.0).curve(), Easing::EaseOut);
}
#[test]
fn a_bounce_holds_its_range() {
assert!((Bounce::new(0.3).get() - 0.3).abs() < 1e-4);
assert_eq!(Bounce::new(0.0).get(), 0.0);
assert_eq!(Bounce::new(-1.0).get(), 0.0);
assert_eq!(Bounce::new(f32::NAN).get(), 0.0);
assert_eq!(Bounce::new(1.0).get(), MAX_BOUNCE, "1 would never settle");
let (_, settled) = peak(Transition::ms(100.0).easing(Easing::Spring).bounce(1.0));
assert!(settled, "the most bounce there is still comes to rest");
}
#[test]
fn a_transition_carries_its_bounce_in_its_padding() {
assert_eq!(std::mem::size_of::<Option<Bounce>>(), 2);
assert_eq!(std::mem::size_of::<Option<Transition>>(), 12);
}
#[test]
fn an_entrance_starts_its_first_leg_from_the_declared_value() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0).easing(Easing::Linear);
a.set_time(0.0);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0],
-100.0,
"first sight starts at the entrance"
);
assert!(a.animating(), "and owes a frame");
a.set_time(0.05);
a.begin_frame(frame.next());
assert!(
(a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0] + 50.0).abs() < 1e-3
);
a.set_time(0.2);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0],
0.0
);
assert!(!a.animating());
a.set_time(0.2);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Pos, Some(one(-100.0)), one(40.0), t, true)[0],
0.0
);
a.set_time(0.25);
a.begin_frame(frame.next());
assert!(
(a.drive(k, Slot::Pos, Some(one(-100.0)), one(40.0), t, true)[0] - 20.0).abs() < 1e-3
);
}
#[test]
fn an_entrance_equal_to_the_target_is_no_entrance() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0);
a.set_time(0.0);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Bg, Some(one(5.0)), one(5.0), t, true)[0],
5.0
);
assert!(!a.animating());
}
#[test]
fn a_spring_entrance_carries_no_velocity_in() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0).easing(Easing::Spring);
a.set_time(0.0);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0],
-100.0
);
a.set_time(0.016);
a.begin_frame(frame.next());
let first = a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0];
assert!(
first > -100.0 && first < 0.0,
"leaves the entrance toward the target: {first}"
);
assert!(a.animating());
let mut v = first;
for i in 2..=90 {
a.set_time(i as f64 * 0.016);
a.begin_frame(frame.next());
v = a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, true)[0];
}
assert!(v.abs() < 1.0, "settled on the target: {v}");
assert!(!a.animating());
}
#[test]
fn off_the_leash_a_settled_slot_snaps_to_a_new_target() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0).easing(Easing::Linear);
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Pos, Some(one(-100.0)), one(0.0), t, false);
a.set_time(0.05);
a.begin_frame(frame.next());
let at = a.drive(k, Slot::Pos, Some(one(-100.0)), one(20.0), t, false)[0];
a.set_time(0.1);
a.begin_frame(frame.next());
let mid = a.drive(k, Slot::Pos, Some(one(-100.0)), one(20.0), t, false)[0];
assert!(
at < mid && mid < 20.0,
"mid-flight retarget keeps easing: {at} -> {mid}"
);
a.set_time(0.3);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Pos, Some(one(-100.0)), one(20.0), t, false)[0],
20.0
);
assert!(!a.animating());
a.set_time(0.3);
a.begin_frame(frame.next());
assert_eq!(
a.drive(k, Slot::Pos, Some(one(-100.0)), one(300.0), t, false)[0],
300.0
);
assert!(!a.animating());
}
#[test]
fn a_target_that_moves_every_frame_still_advances() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("card");
let t = Transition::ms(160.0);
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Pos, None, one(0.0), t, true);
let mut behind = Vec::new();
let mut drawn = 0.0;
for f in 1..=60 {
let target = f as f32 * 9.6;
a.set_time(f as f64 * 0.016);
a.begin_frame(frame.next());
drawn = a.drive(k, Slot::Pos, None, one(target), t, true)[0];
behind.push(target - drawn);
}
let target = 60.0 * 9.6;
assert!(
drawn > target * 0.8,
"the pan reaches the screen: drawn {drawn} of {target}"
);
let (early, late) = (behind[29], behind[59]);
assert!(
(early - late).abs() < 1.0,
"the gap stops growing: {early} then {late}"
);
}
#[test]
fn a_slot_skipped_for_a_frame_snaps() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0);
a.set_time(0.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(0.0), t, true);
a.set_time(0.01);
a.begin_frame(frame.next());
a.set_time(0.02);
a.begin_frame(frame.next());
assert_eq!(a.drive(k, Slot::Width, None, one(100.0), t, true)[0], 100.0);
assert!(!a.animating());
}
#[test]
fn keyframes_cycle_in_every_direction() {
let track = [(0.0, one(0.0)), (1.0, one(100.0))];
let mut frame = Frames(0);
let mut at = |a: &mut AnimStore, repeat: Repeat, now: f64| {
let t = Transition::ms(1000.0).easing(Easing::Linear).repeat(repeat);
a.set_time(now);
a.begin_frame(frame.next());
let v = a.node(Key::ROOT).sample(&track, t).unwrap()[0];
assert!(a.animating(), "a keyframed slot always owes a frame");
v
};
let mut a = AnimStore::default();
for (repeat, expect) in [
(Repeat::Normal, [0.0, 25.0, 50.0, 75.0, 0.0, 25.0]),
(Repeat::Reverse, [100.0, 75.0, 50.0, 25.0, 100.0, 75.0]),
(Repeat::Alternate, [0.0, 25.0, 50.0, 75.0, 100.0, 75.0]),
(
Repeat::AlternateReverse,
[100.0, 75.0, 50.0, 25.0, 0.0, 25.0],
),
] {
for (i, e) in expect.iter().enumerate() {
let v = at(&mut a, repeat, i as f64 * 0.25);
assert!((v - e).abs() < 1e-3, "{repeat:?} at {i}/4: {v} != {e}");
}
}
}
#[test]
fn delay_shifts_the_cycle_and_easing_shapes_each_segment() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let track = [(0.0, one(0.0)), (0.5, one(10.0)), (1.0, one(0.0))];
let t = Transition::ms(1000.0).easing(Easing::Linear);
a.set_time(0.25);
a.begin_frame(frame.next());
assert!((a.node(Key::ROOT).sample(&track, t).unwrap()[0] - 5.0).abs() < 1e-3);
a.set_time(0.25);
a.begin_frame(frame.next());
assert!((a.node(Key::ROOT).sample(&track, t.delay(250.0)).unwrap()[0]).abs() < 1e-3);
a.set_time(0.125);
a.begin_frame(frame.next());
let v = a
.node(Key::ROOT)
.sample(&track, t.easing(Easing::EaseIn))
.unwrap()[0];
assert!(v > 0.0 && v < 2.0, "{v}");
let late = [(0.5, one(3.0)), (1.0, one(9.0))];
a.set_time(0.1);
a.begin_frame(frame.next());
assert_eq!(a.node(Key::ROOT).sample(&late, t).unwrap()[0], 3.0);
}
#[test]
fn keyframes_need_a_clock_and_a_duration() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let track = [(0.0, one(0.0)), (1.0, one(1.0))];
a.begin_frame(frame.next());
assert!(
a.node(Key::ROOT)
.sample(&track, Transition::ms(100.0))
.is_none()
);
assert!(!a.animating());
a.set_time(1.0);
a.begin_frame(frame.next());
assert!(
a.node(Key::ROOT)
.sample(&track, Transition::ms(0.0))
.is_none()
);
assert!(
a.node(Key::ROOT)
.sample(&[], Transition::ms(100.0))
.is_none()
);
assert!(!a.animating());
}
#[test]
fn no_clock_means_no_animation() {
let mut a = AnimStore::default();
let mut frame = Frames(0);
let k = Key::ROOT.str("x");
let t = Transition::ms(100.0);
a.begin_frame(frame.next());
a.drive(k, Slot::Width, None, one(0.0), t, true);
a.begin_frame(frame.next());
assert_eq!(a.drive(k, Slot::Width, None, one(100.0), t, true)[0], 100.0);
assert!(!a.animating());
}
}