use crate::animation::{Animatable, TimingFunction};
use crate::layout::IntoF32;
use crate::reactive::{IntoSignal, Signal};
#[derive(Clone)]
struct Stop<T> {
offset: f32,
value: T,
easing: TimingFunction,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Repeat {
Times(u32),
Forever,
}
#[derive(Clone)]
pub struct Keyframes<T> {
stops: Vec<Stop<T>>,
duration_ms: f32,
repeat: Repeat,
trigger: Option<Signal<u32>>,
}
impl<T: Animatable> Keyframes<T> {
pub fn new(duration_ms: impl IntoF32) -> Self {
Self {
stops: Vec::new(),
duration_ms: duration_ms.into_f32().max(1.0),
repeat: Repeat::Times(1),
trigger: None,
}
}
pub(crate) fn reach(&self, measure: impl Fn(&T) -> f32) -> f32 {
if self.stops.is_empty() {
return 0.0;
}
let (floor, deepest) = self
.stops
.iter()
.map(|stop| measure(&stop.value))
.fold((0.0_f32, f32::NEG_INFINITY), |(low, high), value| {
(low.min(value), high.max(value))
});
let overshoot = self
.stops
.iter()
.map(|stop| stop.easing.peak_overshoot())
.fold(0.0_f32, f32::max);
(deepest + (deepest - floor) * overshoot).max(0.0)
}
pub fn at(self, offset: f32, value: T) -> Self {
self.at_with(offset, value, TimingFunction::Linear)
}
pub fn at_with(mut self, offset: f32, value: T, easing: TimingFunction) -> Self {
let offset = if offset.is_nan() {
0.0
} else {
offset.clamp(0.0, 1.0)
};
let easing = match easing {
TimingFunction::Spring(_) => {
#[cfg(debug_assertions)]
log::warn!(
"keyframes: a spring cannot ease a segment of fixed \
duration; using EaseInOut for the stop at {offset}"
);
TimingFunction::EaseInOut
}
other => other,
};
let stop = Stop {
offset,
value,
easing,
};
let at = self
.stops
.iter()
.position(|existing| existing.offset > offset)
.unwrap_or(self.stops.len());
self.stops.insert(at, stop);
self
}
pub fn repeat(mut self, repeat: Repeat) -> Self {
self.repeat = match repeat {
Repeat::Times(times) => Repeat::Times(times.max(1)),
Repeat::Forever => Repeat::Forever,
};
self
}
pub fn played_by<M>(mut self, trigger: impl IntoSignal<u32, M>) -> Self {
self.trigger = Some(trigger.into_signal());
self
}
pub(crate) fn trigger(&self) -> Option<Signal<u32>> {
self.trigger
}
pub fn total_ms(&self) -> Option<f32> {
match self.repeat {
Repeat::Times(times) => Some(self.duration_ms * times as f32),
Repeat::Forever => None,
}
}
pub(crate) fn duration_ms(&self) -> f32 {
self.duration_ms
}
pub fn is_empty(&self) -> bool {
self.stops.is_empty()
}
pub fn value_at(&self, elapsed_ms: f32) -> Option<T> {
if self.stops.is_empty() {
return None;
}
if self.total_ms().is_some_and(|total| elapsed_ms >= total) {
return None;
}
let within = (elapsed_ms.max(0.0) % self.duration_ms) / self.duration_ms;
Some(self.sample(within))
}
fn sample(&self, t: f32) -> T {
let first = &self.stops[0];
if t <= first.offset {
return first.value;
}
let last = &self.stops[self.stops.len() - 1];
if t >= last.offset {
return last.value;
}
let next = self
.stops
.iter()
.position(|stop| stop.offset > t)
.unwrap_or(self.stops.len() - 1)
.max(1);
let from = &self.stops[next - 1];
let to = &self.stops[next];
let span = to.offset - from.offset;
if span <= f32::EPSILON {
return to.value;
}
let local = (t - from.offset) / span;
T::lerp(&from.value, &to.value, from.easing.evaluate(local))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shake() -> Keyframes<f32> {
Keyframes::new(300.0)
.at(0.0, 0.0)
.at(0.5, 10.0)
.at(1.0, 0.0)
}
#[test]
fn a_reach_covers_the_stops_and_the_overshoot_between_them() {
let flat = Keyframes::new(100.0).at(0.0, 2.0).at(1.0, 20.0);
assert_eq!(
flat.reach(|v| *v),
20.0,
"nothing overshoots a linear segment, so the deepest stop is the reach"
);
let overshooting = TimingFunction::CubicBezier(0.5, -0.6, 0.5, 1.2);
let expected = 20.0 * (1.0 + overshooting.peak_overshoot());
assert!(
expected > 20.5,
"the curve has to actually overshoot for this to test anything"
);
let wound = Keyframes::new(100.0)
.at_with(0.0, 2.0, overshooting)
.at(1.0, 20.0);
assert!(
(wound.reach(|v| *v) - expected).abs() < 1e-3,
"the reach has to cover where the easing travels past the stop: \
expected {expected}, got {}",
wound.reach(|v| *v)
);
}
#[test]
fn a_reach_covers_the_overshoot_between_stops_below_zero() {
let overshooting = TimingFunction::CubicBezier(0.5, -0.6, 0.5, 1.2);
let inside = Keyframes::new(100.0)
.at_with(0.0, -16.0, overshooting.clone())
.at(1.0, -0.2);
let expected = -0.2 + 15.8 * overshooting.peak_overshoot();
assert!(
(inside.reach(|v| *v) - expected).abs() < 1e-3,
"expected {expected}, got {}",
inside.reach(|v| *v)
);
assert_eq!(Keyframes::<f32>::new(100.0).reach(|v| *v), 0.0);
}
#[test]
fn a_timeline_passes_through_its_stops() {
let kf = shake();
assert_eq!(kf.value_at(0.0), Some(0.0));
assert_eq!(kf.value_at(150.0), Some(10.0));
assert_eq!(kf.value_at(75.0), Some(5.0), "halfway up the first segment");
assert_eq!(kf.value_at(225.0), Some(5.0), "and halfway back down");
}
#[test]
fn a_finished_timeline_has_no_value_of_its_own() {
assert_eq!(shake().value_at(300.0), None);
assert_eq!(shake().value_at(1000.0), None);
}
#[test]
fn stops_written_out_of_order_are_put_in_order() {
let kf = Keyframes::new(100.0)
.at(1.0, 0.0)
.at(0.0, 0.0)
.at(0.5, 10.0);
assert_eq!(kf.value_at(50.0), Some(10.0));
}
#[test]
fn the_ends_hold_rather_than_extrapolate() {
let kf = Keyframes::new(100.0).at(0.25, 4.0).at(0.75, 8.0);
assert_eq!(kf.value_at(0.0), Some(4.0));
assert_eq!(kf.value_at(90.0), Some(8.0));
}
#[test]
fn a_repeat_plays_the_same_run_again() {
let kf = shake().repeat(Repeat::Times(2));
assert_eq!(kf.total_ms(), Some(600.0));
assert_eq!(kf.value_at(150.0), Some(10.0));
assert_eq!(kf.value_at(450.0), Some(10.0), "the peak of the second run");
assert_eq!(kf.value_at(600.0), None);
}
#[test]
fn a_count_of_zero_is_kept_as_the_one_run_it_means() {
assert_eq!(shake().repeat(Repeat::Times(0)).repeat, Repeat::Times(1));
}
#[test]
fn easing_declared_at_a_stop_governs_the_segment_leaving_it() {
let eased = Keyframes::new(100.0)
.at_with(0.0, 0.0, TimingFunction::EaseIn)
.at(1.0, 10.0);
let linear = Keyframes::new(100.0).at(0.0, 0.0).at(1.0, 10.0);
assert!(eased.value_at(25.0).unwrap() < linear.value_at(25.0).unwrap());
}
#[test]
fn an_empty_timeline_plays_nothing() {
let kf: Keyframes<f32> = Keyframes::new(100.0);
assert!(kf.is_empty());
assert_eq!(kf.value_at(0.0), None);
}
#[test]
fn a_nan_offset_does_not_walk_off_the_front() {
let kf = Keyframes::new(100.0).at(f32::NAN, 7.0_f32);
assert_eq!(kf.value_at(0.0), Some(7.0));
assert_eq!(kf.value_at(50.0), Some(7.0));
}
#[test]
fn a_nan_offset_lands_at_the_start() {
let kf = Keyframes::new(100.0).at(f32::NAN, 0.0_f32).at(1.0, 10.0);
assert_eq!(kf.value_at(0.0), Some(0.0));
assert_eq!(kf.value_at(99.0).map(|v| v > 9.0), Some(true));
}
#[test]
fn a_spring_easing_is_replaced_rather_than_played_flat() {
use crate::animation::SpringConfig;
let sprung = Keyframes::new(100.0)
.at_with(0.0, 0.0_f32, TimingFunction::Spring(SpringConfig::BOUNCY))
.at(1.0, 10.0);
let eased = Keyframes::new(100.0)
.at_with(0.0, 0.0_f32, TimingFunction::EaseInOut)
.at(1.0, 10.0);
assert_eq!(sprung.value_at(25.0), eased.value_at(25.0));
assert_ne!(
sprung.value_at(25.0),
Keyframes::new(100.0)
.at(0.0, 0.0_f32)
.at(1.0, 10.0)
.value_at(25.0),
"and it is not the linear one either"
);
}
}