use std::time::Duration;
use web_time::Instant;
use crate::tree::Color;
pub mod tick;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AnimValue {
Float(f32),
Color(Color),
}
impl AnimValue {
pub fn channels(self) -> AnimChannels {
match self {
AnimValue::Float(v) => AnimChannels {
n: 1,
v: [v, 0.0, 0.0, 0.0],
},
AnimValue::Color(c) => AnimChannels {
n: 4,
v: [c.r as f32, c.g as f32, c.b as f32, c.a as f32],
},
}
}
pub fn from_channels(self, ch: AnimChannels) -> AnimValue {
match self {
AnimValue::Float(_) => AnimValue::Float(ch.v[0]),
AnimValue::Color(_) => AnimValue::Color(Color {
r: ch.v[0].round().clamp(0.0, 255.0) as u8,
g: ch.v[1].round().clamp(0.0, 255.0) as u8,
b: ch.v[2].round().clamp(0.0, 255.0) as u8,
a: ch.v[3].round().clamp(0.0, 255.0) as u8,
token: None,
}),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct AnimChannels {
pub n: usize,
pub v: [f32; 4],
}
impl AnimChannels {
pub fn zero(n: usize) -> Self {
Self { n, v: [0.0; 4] }
}
}
#[derive(Clone, Copy, Debug)]
pub struct SpringConfig {
pub mass: f32,
pub stiffness: f32,
pub damping: f32,
}
impl SpringConfig {
pub const QUICK: Self = Self {
mass: 1.0,
stiffness: 380.0,
damping: 30.0,
};
pub const STANDARD: Self = Self {
mass: 1.0,
stiffness: 200.0,
damping: 22.0,
};
pub const BOUNCY: Self = Self {
mass: 1.0,
stiffness: 240.0,
damping: 14.0,
};
pub const GENTLE: Self = Self {
mass: 1.0,
stiffness: 80.0,
damping: 18.0,
};
}
#[derive(Clone, Copy, Debug)]
pub struct TweenConfig {
pub duration: Duration,
pub p1: (f32, f32),
pub p2: (f32, f32),
}
impl TweenConfig {
pub const EASE_QUICK: Self = Self {
duration: Duration::from_millis(100),
p1: (0.0, 0.0),
p2: (0.2, 1.0),
};
pub const EASE_STANDARD: Self = Self {
duration: Duration::from_millis(200),
p1: (0.4, 0.0),
p2: (0.2, 1.0),
};
pub const EASE_EMPHASIZED: Self = Self {
duration: Duration::from_millis(350),
p1: (0.05, 0.7),
p2: (0.1, 1.0),
};
}
#[derive(Clone, Copy, Debug)]
pub enum Timing {
Spring(SpringConfig),
Tween(TweenConfig),
}
impl Timing {
pub const SPRING_QUICK: Self = Timing::Spring(SpringConfig::QUICK);
pub const SPRING_STANDARD: Self = Timing::Spring(SpringConfig::STANDARD);
pub const SPRING_BOUNCY: Self = Timing::Spring(SpringConfig::BOUNCY);
pub const SPRING_GENTLE: Self = Timing::Spring(SpringConfig::GENTLE);
pub const EASE_QUICK: Self = Timing::Tween(TweenConfig::EASE_QUICK);
pub const EASE_STANDARD: Self = Timing::Tween(TweenConfig::EASE_STANDARD);
pub const EASE_EMPHASIZED: Self = Timing::Tween(TweenConfig::EASE_EMPHASIZED);
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnimProp {
HoverAmount,
PressAmount,
FocusRingAlpha,
SubtreeHoverAmount,
SubtreePressAmount,
SubtreeFocusAmount,
AppFill,
AppStroke,
AppTextColor,
AppOpacity,
AppScale,
AppTranslateX,
AppTranslateY,
}
const SPRING_EPSILON_DISP: f32 = 0.5;
const SPRING_EPSILON_VEL: f32 = 0.5;
const DT_CAP: f32 = 0.064;
const SPRING_MAX_SUBSTEP: f32 = 1.0 / 250.0;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Animation {
pub current: AnimValue,
pub target: AnimValue,
pub velocity: AnimChannels,
pub timing: Timing,
pub started_at: Instant,
pub last_step: Instant,
pub from: Option<AnimValue>,
}
impl Animation {
pub fn new(current: AnimValue, target: AnimValue, timing: Timing, now: Instant) -> Self {
let n = current.channels().n;
let from = match timing {
Timing::Tween(_) => Some(current),
Timing::Spring(_) => None,
};
Self {
current,
target,
velocity: AnimChannels::zero(n),
timing,
started_at: now,
last_step: now,
from,
}
}
pub fn retarget(&mut self, target: AnimValue, now: Instant) {
if same_value(self.target, target) {
return;
}
self.target = target;
if matches!(self.timing, Timing::Tween(_)) {
self.from = Some(self.current);
self.started_at = now;
}
}
pub fn settle(&mut self) {
self.current = self.target;
let n = self.current.channels().n;
self.velocity = AnimChannels::zero(n);
self.from = None;
}
pub fn step(&mut self, now: Instant) -> bool {
let dt = now
.saturating_duration_since(self.last_step)
.as_secs_f32()
.min(DT_CAP);
self.last_step = now;
match self.timing {
Timing::Spring(cfg) => self.step_spring(cfg, dt),
Timing::Tween(cfg) => self.step_tween(cfg, now),
}
}
fn step_spring(&mut self, cfg: SpringConfig, dt: f32) -> bool {
if dt <= 0.0 {
return self.is_settled();
}
let mut cur = self.current.channels();
let tgt = self.target.channels();
let mut vel = if self.velocity.n == cur.n {
self.velocity
} else {
AnimChannels::zero(cur.n)
};
let n_steps = (dt / SPRING_MAX_SUBSTEP).ceil().max(1.0) as usize;
let h = dt / n_steps as f32;
let mut all_settled = false;
for _ in 0..n_steps {
all_settled = true;
for i in 0..cur.n {
let displacement = cur.v[i] - tgt.v[i];
let force = -cfg.stiffness * displacement - cfg.damping * vel.v[i];
vel.v[i] += (force / cfg.mass) * h;
cur.v[i] += vel.v[i] * h;
if displacement.abs() > SPRING_EPSILON_DISP || vel.v[i].abs() > SPRING_EPSILON_VEL {
all_settled = false;
}
}
if all_settled {
break;
}
}
if all_settled {
self.current = self.target;
self.velocity = AnimChannels::zero(cur.n);
return true;
}
self.current = self.current.from_channels(cur);
self.velocity = vel;
false
}
fn step_tween(&mut self, cfg: TweenConfig, now: Instant) -> bool {
let elapsed = now.saturating_duration_since(self.started_at);
if elapsed >= cfg.duration {
self.current = self.target;
return true;
}
let from = self.from.unwrap_or(self.current).channels();
let tgt = self.target.channels();
let t = elapsed.as_secs_f32() / cfg.duration.as_secs_f32();
let eased = cubic_bezier_y_at_x(t, cfg.p1, cfg.p2);
let mut next = AnimChannels {
n: from.n,
v: [0.0; 4],
};
for i in 0..from.n {
next.v[i] = from.v[i] + (tgt.v[i] - from.v[i]) * eased;
}
self.current = self.current.from_channels(next);
false
}
fn is_settled(&self) -> bool {
same_value(self.current, self.target)
&& (0..self.velocity.n).all(|i| self.velocity.v[i].abs() <= SPRING_EPSILON_VEL)
}
}
fn same_value(a: AnimValue, b: AnimValue) -> bool {
let ca = a.channels();
let cb = b.channels();
if ca.n != cb.n {
return false;
}
(0..ca.n).all(|i| (ca.v[i] - cb.v[i]).abs() < f32::EPSILON)
}
fn cubic_bezier_y_at_x(x: f32, p1: (f32, f32), p2: (f32, f32)) -> f32 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let mut t = x;
for _ in 0..8 {
let xt = bezier_axis(t, p1.0, p2.0);
let dx = bezier_axis_derivative(t, p1.0, p2.0);
if dx.abs() < 1e-6 {
break;
}
let next = t - (xt - x) / dx;
if (next - t).abs() < 1e-5 {
t = next.clamp(0.0, 1.0);
break;
}
t = next.clamp(0.0, 1.0);
}
bezier_axis(t, p1.1, p2.1)
}
fn bezier_axis(t: f32, c1: f32, c2: f32) -> f32 {
let one_minus_t = 1.0 - t;
3.0 * one_minus_t * one_minus_t * t * c1 + 3.0 * one_minus_t * t * t * c2 + t * t * t
}
fn bezier_axis_derivative(t: f32, c1: f32, c2: f32) -> f32 {
let one_minus_t = 1.0 - t;
3.0 * one_minus_t * one_minus_t * c1
+ 6.0 * one_minus_t * t * (c2 - c1)
+ 3.0 * t * t * (1.0 - c2)
}
#[cfg(test)]
mod tests {
use super::*;
fn now_plus(start: Instant, ms: u64) -> Instant {
start + Duration::from_millis(ms)
}
#[test]
fn spring_settles_to_target() {
let start = Instant::now();
let mut a = Animation::new(
AnimValue::Float(0.0),
AnimValue::Float(1.0),
Timing::SPRING_QUICK,
start,
);
let mut t = start;
for _ in 0..200 {
t += Duration::from_millis(8);
if a.step(t) {
break;
}
}
let AnimValue::Float(v) = a.current else {
panic!("expected float")
};
assert!((v - 1.0).abs() < 1e-3, "spring did not settle: v={v}");
}
#[test]
fn spring_retarget_preserves_velocity() {
let start = Instant::now();
let mut a = Animation::new(
AnimValue::Float(0.0),
AnimValue::Float(1.0),
Timing::SPRING_STANDARD,
start,
);
let mut t = start;
for _ in 0..15 {
t += Duration::from_millis(8);
a.step(t);
}
let mid = match a.current {
AnimValue::Float(v) => v,
_ => unreachable!(),
};
assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}");
let velocity_before = a.velocity.v[0];
assert!(velocity_before > 0.0);
a.retarget(AnimValue::Float(0.0), t);
assert_eq!(a.velocity.v[0], velocity_before);
}
#[test]
fn tween_samples_endpoints() {
let start = Instant::now();
let mut a = Animation::new(
AnimValue::Float(10.0),
AnimValue::Float(20.0),
Timing::EASE_STANDARD,
start,
);
a.step(start);
let AnimValue::Float(v0) = a.current else {
panic!()
};
assert!(
(v0 - 10.0).abs() < 1e-3,
"tween at t=0 should equal `from`, got {v0}"
);
a.step(now_plus(start, 1000));
let AnimValue::Float(vend) = a.current else {
panic!()
};
assert!(
(vend - 20.0).abs() < 1e-3,
"tween past duration should equal target, got {vend}"
);
}
#[test]
fn tween_retarget_snaps_from_to_current() {
let start = Instant::now();
let mut a = Animation::new(
AnimValue::Float(0.0),
AnimValue::Float(100.0),
Timing::EASE_STANDARD,
start,
);
a.step(now_plus(start, 100));
let AnimValue::Float(mid) = a.current else {
panic!()
};
a.retarget(AnimValue::Float(0.0), now_plus(start, 100));
assert_eq!(a.from, Some(AnimValue::Float(mid)));
}
#[test]
fn settle_snaps_to_target() {
let start = Instant::now();
let mut a = Animation::new(
AnimValue::Color(Color::rgba(0, 0, 0, 255)),
AnimValue::Color(Color::rgba(255, 128, 0, 255)),
Timing::SPRING_STANDARD,
start,
);
a.step(now_plus(start, 5));
a.settle();
match a.current {
AnimValue::Color(c) => {
assert_eq!((c.r, c.g, c.b, c.a), (255, 128, 0, 255));
}
_ => panic!("expected color"),
}
assert!(a.velocity.v.iter().all(|&v| v == 0.0));
}
#[test]
fn cubic_bezier_endpoints_pin() {
let p1 = (0.4, 0.0);
let p2 = (0.2, 1.0);
assert!((cubic_bezier_y_at_x(0.0, p1, p2) - 0.0).abs() < 1e-3);
assert!((cubic_bezier_y_at_x(1.0, p1, p2) - 1.0).abs() < 1e-3);
}
#[test]
fn color_channels_round_trip() {
let c = Color::rgba(42, 17, 200, 255);
let v = AnimValue::Color(c);
let ch = v.channels();
assert_eq!(ch.n, 4);
assert_eq!(ch.v, [42.0, 17.0, 200.0, 255.0]);
let back = v.from_channels(ch);
assert_eq!(back, AnimValue::Color(c));
}
#[test]
fn from_channels_drops_token_on_in_flight_eased_value() {
let v = AnimValue::Color(Color::token("primary", 92, 170, 255, 255));
let mid = AnimChannels {
n: 4,
v: [128.0, 100.0, 80.0, 255.0],
};
let eased = v.from_channels(mid);
match eased {
AnimValue::Color(c) => {
assert_eq!(c.token, None, "in-flight eased color must drop the token");
assert_eq!((c.r, c.g, c.b), (128, 100, 80));
}
_ => panic!("expected color"),
}
}
}