use std::sync::LazyLock;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::generated::catalog as g;
use crate::theme::ResolvedTheme;
#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Keyframe {
pub offset: f64,
pub opacity: Option<f64>,
pub translate_x: Option<f64>,
pub translate_y: Option<f64>,
pub rotate: Option<f64>,
pub scale: Option<f64>,
pub band: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Band {
pub color: String,
pub stops: Vec<BandStop>,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
pub struct BandStop {
pub offset: f64,
pub alpha: f64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AnimationDef {
pub duration: String,
pub factor: f64,
pub easing: String,
pub iterations: Value,
pub rest: f64,
pub keyframes: Vec<Keyframe>,
#[serde(default)]
pub band: Option<Band>,
}
pub static ANIMATIONS: LazyLock<IndexMap<String, AnimationDef>> = LazyLock::new(|| {
let style: Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
style["animations"]
.as_object()
.map(|m| m.iter().filter(|(k, _)| !k.starts_with('$')).map(|(k, v)| (k.clone(), serde_json::from_value(v.clone()).expect("animation"))).collect())
.unwrap_or_default()
});
pub const ANIMATION_NAMES: &[&str] = g::ANIMATION_NAMES;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AnimationEasing {
Bezier([f64; 4]),
Linear(LinearTag),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LinearTag {
Linear,
}
impl AnimationEasing {
pub const LINEAR: AnimationEasing = AnimationEasing::Linear(LinearTag::Linear);
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Iterations {
Count(f64),
Infinite(InfiniteTag),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InfiniteTag {
Infinite,
}
impl Iterations {
pub const INFINITE: Iterations = Iterations::Infinite(InfiniteTag::Infinite);
pub fn count(self) -> f64 {
match self {
Iterations::Count(n) => n,
Iterations::Infinite(_) => f64::INFINITY,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnimationTiming {
pub duration_ms: f64,
pub easing: AnimationEasing,
pub iterations: Iterations,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnimationFrame {
pub opacity: f64,
pub translate_x: f64,
pub translate_y: f64,
pub rotate: f64,
pub scale: f64,
pub band: Option<f64>,
}
pub const REST: AnimationFrame = AnimationFrame { opacity: 1.0, translate_x: 0.0, translate_y: 0.0, rotate: 0.0, scale: 1.0, band: None };
fn token(s: &str) -> Option<(&str, &str)> {
s.strip_prefix('$')?.split_once('.')
}
pub fn animation_timing(name: &str, theme: &ResolvedTheme, duration_token: Option<&str>) -> Option<AnimationTiming> {
let def = ANIMATIONS.get(name)?;
let motion = &theme.tokens.motion;
let base = token(duration_token.unwrap_or(&def.duration)).filter(|(g, _)| *g == "motion").and_then(|(_, n)| motion.get(n).copied());
let fallback = token(&def.duration).and_then(|(_, n)| motion.get(n).copied());
let ms = base.or(fallback).unwrap_or(0.0) * def.factor;
let easing = match token(&def.easing) {
Some(("ease", n)) if def.easing != "linear" => theme.tokens.ease.get(n).filter(|c| c.len() == 4).map(|c| AnimationEasing::Bezier([c[0], c[1], c[2], c[3]])).unwrap_or(AnimationEasing::LINEAR),
_ => AnimationEasing::LINEAR,
};
let iterations = match &def.iterations {
Value::Number(n) => Iterations::Count(n.as_f64().unwrap_or(1.0)),
_ => Iterations::INFINITE,
};
Some(AnimationTiming { duration_ms: ms, easing, iterations })
}
pub fn animation_timing_ms(name: &str, theme: &ResolvedTheme, duration_ms: Option<f64>) -> Option<AnimationTiming> {
let mut t = animation_timing(name, theme, None)?;
if let Some(ms) = duration_ms.filter(|m| m.is_finite()) {
t.duration_ms = ms * ANIMATIONS.get(name)?.factor;
}
Some(t)
}
pub fn frame_with_timing(name: &str, timing: &AnimationTiming, elapsed_ms: f64, reduced_motion: bool) -> Option<AnimationFrame> {
let def = ANIMATIONS.get(name)?;
if reduced_motion {
return Some(AnimationFrame { band: None, ..frame_at(def, def.rest, &AnimationEasing::LINEAR) });
}
let d = timing.duration_ms;
if d <= 0.0 {
return Some(frame_at(def, 1.0, &AnimationEasing::LINEAR));
}
let t = elapsed_ms.max(0.0);
if t >= d * timing.iterations.count() {
return Some(frame_at(def, 1.0, &timing.easing));
}
Some(frame_at(def, (t % d) / d, &timing.easing))
}
pub fn cubic_bezier(curve: [f64; 4], x: f64) -> f64 {
let [x1, y1, x2, y2] = curve;
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let bx = |t: f64| 3.0 * x1 * t * (1.0 - t).powi(2) + 3.0 * x2 * t * t * (1.0 - t) + t.powi(3);
let by = |t: f64| 3.0 * y1 * t * (1.0 - t).powi(2) + 3.0 * y2 * t * t * (1.0 - t) + t.powi(3);
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
if bx(mid) < x {
lo = mid;
} else {
hi = mid;
}
}
by((lo + hi) / 2.0)
}
fn r4(v: f64) -> f64 {
let r = (v * 1e4).round() / 1e4;
if r == 0.0 {
0.0
} else {
r
}
}
fn frame_at(def: &AnimationDef, progress: f64, easing: &AnimationEasing) -> AnimationFrame {
let ks = &def.keyframes;
let mut k = 0;
while k + 2 < ks.len() && progress > ks[k + 1].offset {
k += 1;
}
let a = ks[k];
let b = ks[(k + 1).min(ks.len() - 1)];
let span = b.offset - a.offset;
let local = if span > 0.0 { ((progress - a.offset) / span).clamp(0.0, 1.0) } else { 1.0 };
let t = match easing {
AnimationEasing::Bezier(c) => cubic_bezier(*c, local),
AnimationEasing::Linear(_) => local,
};
let lerp = |va: Option<f64>, vb: Option<f64>, rest: f64| {
let (va, vb) = (va.unwrap_or(rest), vb.unwrap_or(rest));
r4(va + (vb - va) * t)
};
AnimationFrame {
opacity: lerp(a.opacity, b.opacity, REST.opacity),
translate_x: lerp(a.translate_x, b.translate_x, REST.translate_x),
translate_y: lerp(a.translate_y, b.translate_y, REST.translate_y),
rotate: lerp(a.rotate, b.rotate, REST.rotate),
scale: lerp(a.scale, b.scale, REST.scale),
band: match (a.band, b.band) {
(Some(x), Some(y)) => Some(r4(x + (y - x) * t)),
_ => None,
},
}
}
pub fn animation_frame(name: &str, elapsed_ms: f64, theme: &ResolvedTheme, reduced_motion: bool, duration_token: Option<&str>) -> Option<AnimationFrame> {
let timing = animation_timing(name, theme, duration_token)?;
frame_with_timing(name, &timing, elapsed_ms, reduced_motion)
}
pub fn animation_running(name: &str, elapsed_ms: f64, theme: &ResolvedTheme, reduced_motion: bool, duration_token: Option<&str>) -> bool {
match animation_timing(name, theme, duration_token) {
Some(t) if !reduced_motion && t.duration_ms > 0.0 => elapsed_ms < t.duration_ms * t.iterations.count(),
_ => false,
}
}
fn css(n: f64) -> String {
crate::json::number_to_string(r4(n))
}
pub fn painted_opacity(own: Option<f64>, frame: Option<&AnimationFrame>) -> f64 {
r4(own.unwrap_or(1.0) * frame.map(|f| f.opacity).unwrap_or(1.0))
}
pub const ANIMATION_PROPERTIES_CSS: &str = r#"@property --xui-a-opacity{syntax:"<number>";inherits:false;initial-value:1}@property --xui-band{syntax:"<number>";inherits:true;initial-value:1}"#;
pub fn animates_opacity(name: &str) -> bool {
ANIMATIONS.get(name).is_some_and(|d| d.keyframes.iter().any(|k| k.opacity.is_some()))
}
pub fn keyframes_css(name: &str) -> String {
let Some(def) = ANIMATIONS.get(name) else { return String::new() };
let steps: Vec<String> = def
.keyframes
.iter()
.map(|k| {
let mut decl = Vec::new();
if let Some(o) = k.opacity {
decl.push(format!("--xui-a-opacity:{}", css(o)));
}
if k.translate_x.is_some() || k.translate_y.is_some() {
decl.push(format!("translate:{}px {}px", css(k.translate_x.unwrap_or(0.0)), css(k.translate_y.unwrap_or(0.0))));
}
if let Some(r) = k.rotate {
decl.push(format!("rotate:{}deg", css(r)));
}
if let Some(s) = k.scale {
decl.push(format!("scale:{}", css(s)));
}
if let Some(b) = k.band {
decl.push(format!("--xui-band:{}", css(b)));
}
format!("{}%{{{}}}", css(k.offset * 100.0), decl.join(";"))
})
.collect();
format!("@keyframes xui-{name}{{{}}}", steps.join(""))
}