Skip to main content

eui_core/
animation.rs

1//! Round 2 (docs/round-2-contract.md §2, animation): the token-only
2//! `animation` style key. `catalog/style.json` `animations` holds the
3//! keyframe sets; this module turns one into what a painter needs at a
4//! moment — the duration from the theme's motion tokens, per-segment easing,
5//! the channel values (opacity, translate, rotate, scale, the shimmer band)
6//! — and into CSS `@keyframes`. Mirrors `src/animation.ts`;
7//! `fixtures/animations.json` locks the sampled frames.
8//!
9//! A painter keeps the moment the node entered the tree (first paint,
10//! `visible` turning true, a template item appearing) and asks
11//! [`animation_frame`] each frame; channels compose OUTSIDE the node's own
12//! `transform`, about the box centre; opacity multiplies.
13
14use std::sync::LazyLock;
15
16use indexmap::IndexMap;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::generated::catalog as g;
21use crate::theme::ResolvedTheme;
22
23/// One keyframe (absent channels = the rest value).
24#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Keyframe {
27    pub offset: f64,
28    pub opacity: Option<f64>,
29    pub translate_x: Option<f64>,
30    pub translate_y: Option<f64>,
31    pub rotate: Option<f64>,
32    pub scale: Option<f64>,
33    pub band: Option<f64>,
34}
35
36/// The shimmer band: its colour token and alpha stops across the band.
37#[derive(Debug, Clone, PartialEq, Deserialize)]
38pub struct Band {
39    pub color: String,
40    pub stops: Vec<BandStop>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
44pub struct BandStop {
45    pub offset: f64,
46    pub alpha: f64,
47}
48
49/// A keyframe set (`style.json animations.<name>`).
50#[derive(Debug, Clone, PartialEq, Deserialize)]
51pub struct AnimationDef {
52    pub duration: String,
53    pub factor: f64,
54    pub easing: String,
55    /// A count or `"infinite"`.
56    pub iterations: Value,
57    /// The reduced-motion keyframe offset (0 = first, 1 = last).
58    pub rest: f64,
59    pub keyframes: Vec<Keyframe>,
60    #[serde(default)]
61    pub band: Option<Band>,
62}
63
64/// The keyframe sets by name, in `style.json` order.
65pub static ANIMATIONS: LazyLock<IndexMap<String, AnimationDef>> = LazyLock::new(|| {
66    let style: Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
67    style["animations"]
68        .as_object()
69        .map(|m| m.iter().filter(|(k, _)| !k.starts_with('$')).map(|(k, v)| (k.clone(), serde_json::from_value(v.clone()).expect("animation"))).collect())
70        .unwrap_or_default()
71});
72
73/// The names (`pulse spin fade-in slide-in-up/down/left/right shimmer`).
74pub const ANIMATION_NAMES: &[&str] = g::ANIMATION_NAMES;
75
76/// An easing: a cubic bezier `[x1, y1, x2, y2]` or linear.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum AnimationEasing {
80    Bezier([f64; 4]),
81    Linear(LinearTag),
82}
83
84/// The literal `"linear"`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum LinearTag {
88    Linear,
89}
90
91impl AnimationEasing {
92    pub const LINEAR: AnimationEasing = AnimationEasing::Linear(LinearTag::Linear);
93}
94
95/// Iterations: a count or infinite.
96#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
97#[serde(untagged)]
98pub enum Iterations {
99    Count(f64),
100    Infinite(InfiniteTag),
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum InfiniteTag {
106    Infinite,
107}
108
109impl Iterations {
110    pub const INFINITE: Iterations = Iterations::Infinite(InfiniteTag::Infinite);
111
112    pub fn count(self) -> f64 {
113        match self {
114            Iterations::Count(n) => n,
115            Iterations::Infinite(_) => f64::INFINITY,
116        }
117    }
118}
119
120/// Duration and easing of an animation under a theme.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct AnimationTiming {
124    pub duration_ms: f64,
125    pub easing: AnimationEasing,
126    pub iterations: Iterations,
127}
128
129/// One frame: every channel at its value (`band` `None` = no shimmer band).
130#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct AnimationFrame {
133    pub opacity: f64,
134    pub translate_x: f64,
135    pub translate_y: f64,
136    pub rotate: f64,
137    pub scale: f64,
138    pub band: Option<f64>,
139}
140
141pub const REST: AnimationFrame = AnimationFrame { opacity: 1.0, translate_x: 0.0, translate_y: 0.0, rotate: 0.0, scale: 1.0, band: None };
142
143fn token(s: &str) -> Option<(&str, &str)> {
144    s.strip_prefix('$')?.split_once('.')
145}
146
147/// Duration and easing of `name` under `theme`: the `duration` token (or
148/// `duration_token`, the node's `animationDuration`) × `factor`.
149pub fn animation_timing(name: &str, theme: &ResolvedTheme, duration_token: Option<&str>) -> Option<AnimationTiming> {
150    let def = ANIMATIONS.get(name)?;
151    let motion = &theme.tokens.motion;
152    let base = token(duration_token.unwrap_or(&def.duration)).filter(|(g, _)| *g == "motion").and_then(|(_, n)| motion.get(n).copied());
153    let fallback = token(&def.duration).and_then(|(_, n)| motion.get(n).copied());
154    let ms = base.or(fallback).unwrap_or(0.0) * def.factor;
155    let easing = match token(&def.easing) {
156        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),
157        _ => AnimationEasing::LINEAR,
158    };
159    let iterations = match &def.iterations {
160        Value::Number(n) => Iterations::Count(n.as_f64().unwrap_or(1.0)),
161        _ => Iterations::INFINITE,
162    };
163    Some(AnimationTiming { duration_ms: ms, easing, iterations })
164}
165
166/// [`animation_timing`] with the node's `animationDuration` already
167/// resolved to ms (`$motion.<name>` → its value): it replaces the set's
168/// duration token, the factor is kept.
169pub fn animation_timing_ms(name: &str, theme: &ResolvedTheme, duration_ms: Option<f64>) -> Option<AnimationTiming> {
170    let mut t = animation_timing(name, theme, None)?;
171    if let Some(ms) = duration_ms.filter(|m| m.is_finite()) {
172        t.duration_ms = ms * ANIMATIONS.get(name)?.factor;
173    }
174    Some(t)
175}
176
177/// The frame of `name` under a RESOLVED timing (what a painter keeps from
178/// [`crate::style::Visual::animation`]; no theme needed).
179pub fn frame_with_timing(name: &str, timing: &AnimationTiming, elapsed_ms: f64, reduced_motion: bool) -> Option<AnimationFrame> {
180    let def = ANIMATIONS.get(name)?;
181    if reduced_motion {
182        return Some(AnimationFrame { band: None, ..frame_at(def, def.rest, &AnimationEasing::LINEAR) });
183    }
184    let d = timing.duration_ms;
185    if d <= 0.0 {
186        return Some(frame_at(def, 1.0, &AnimationEasing::LINEAR));
187    }
188    let t = elapsed_ms.max(0.0);
189    if t >= d * timing.iterations.count() {
190        return Some(frame_at(def, 1.0, &timing.easing));
191    }
192    Some(frame_at(def, (t % d) / d, &timing.easing))
193}
194
195/// y of a CSS cubic-bezier at x (bisection on x, 60 steps: deterministic).
196pub fn cubic_bezier(curve: [f64; 4], x: f64) -> f64 {
197    let [x1, y1, x2, y2] = curve;
198    if x <= 0.0 {
199        return 0.0;
200    }
201    if x >= 1.0 {
202        return 1.0;
203    }
204    let bx = |t: f64| 3.0 * x1 * t * (1.0 - t).powi(2) + 3.0 * x2 * t * t * (1.0 - t) + t.powi(3);
205    let by = |t: f64| 3.0 * y1 * t * (1.0 - t).powi(2) + 3.0 * y2 * t * t * (1.0 - t) + t.powi(3);
206    let (mut lo, mut hi) = (0.0, 1.0);
207    for _ in 0..60 {
208        let mid = (lo + hi) / 2.0;
209        if bx(mid) < x {
210            lo = mid;
211        } else {
212            hi = mid;
213        }
214    }
215    by((lo + hi) / 2.0)
216}
217
218fn r4(v: f64) -> f64 {
219    let r = (v * 1e4).round() / 1e4;
220    if r == 0.0 {
221        0.0
222    } else {
223        r
224    }
225}
226
227fn frame_at(def: &AnimationDef, progress: f64, easing: &AnimationEasing) -> AnimationFrame {
228    let ks = &def.keyframes;
229    let mut k = 0;
230    while k + 2 < ks.len() && progress > ks[k + 1].offset {
231        k += 1;
232    }
233    let a = ks[k];
234    let b = ks[(k + 1).min(ks.len() - 1)];
235    let span = b.offset - a.offset;
236    let local = if span > 0.0 { ((progress - a.offset) / span).clamp(0.0, 1.0) } else { 1.0 };
237    let t = match easing {
238        AnimationEasing::Bezier(c) => cubic_bezier(*c, local),
239        AnimationEasing::Linear(_) => local,
240    };
241    let lerp = |va: Option<f64>, vb: Option<f64>, rest: f64| {
242        let (va, vb) = (va.unwrap_or(rest), vb.unwrap_or(rest));
243        r4(va + (vb - va) * t)
244    };
245    AnimationFrame {
246        opacity: lerp(a.opacity, b.opacity, REST.opacity),
247        translate_x: lerp(a.translate_x, b.translate_x, REST.translate_x),
248        translate_y: lerp(a.translate_y, b.translate_y, REST.translate_y),
249        rotate: lerp(a.rotate, b.rotate, REST.rotate),
250        scale: lerp(a.scale, b.scale, REST.scale),
251        band: match (a.band, b.band) {
252            (Some(x), Some(y)) => Some(r4(x + (y - x) * t)),
253            _ => None,
254        },
255    }
256}
257
258/// The frame `elapsed_ms` after the node entered the tree. Finished finite
259/// animations hold their last keyframe; `reduced_motion` = the `rest`
260/// keyframe, no band.
261pub fn animation_frame(name: &str, elapsed_ms: f64, theme: &ResolvedTheme, reduced_motion: bool, duration_token: Option<&str>) -> Option<AnimationFrame> {
262    let timing = animation_timing(name, theme, duration_token)?;
263    frame_with_timing(name, &timing, elapsed_ms, reduced_motion)
264}
265
266/// Whether the animation still moves at `elapsed_ms` (a painter stops
267/// requesting frames once a finite one finished).
268pub fn animation_running(name: &str, elapsed_ms: f64, theme: &ResolvedTheme, reduced_motion: bool, duration_token: Option<&str>) -> bool {
269    match animation_timing(name, theme, duration_token) {
270        Some(t) if !reduced_motion && t.duration_ms > 0.0 => elapsed_ms < t.duration_ms * t.iterations.count(),
271        _ => false,
272    }
273}
274
275fn css(n: f64) -> String {
276    crate::json::number_to_string(r4(n))
277}
278
279/// The painted opacity of an animating node: its own (default 1) × the
280/// frame's, never replaced (a finished fade-in keeps the author's
281/// opacity). `paintedOpacity`.
282pub fn painted_opacity(own: Option<f64>, frame: Option<&AnimationFrame>) -> f64 {
283    r4(own.unwrap_or(1.0) * frame.map(|f| f.opacity).unwrap_or(1.0))
284}
285
286/// The custom properties the keyframes animate, registered so they
287/// interpolate (`--xui-a-opacity`, the opacity factor; `--xui-band`, the
288/// shimmer band). A CSS renderer adds this ONCE per document.
289pub 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}"#;
290
291/// Whether a keyframe set animates opacity (`animatesOpacity`).
292pub fn animates_opacity(name: &str) -> bool {
293    ANIMATIONS.get(name).is_some_and(|d| d.keyframes.iter().any(|k| k.opacity.is_some()))
294}
295
296/// The CSS `@keyframes xui-<name>` rule (`keyframesCss`): opacity moves
297/// `--xui-a-opacity` (the node's `opacity` multiplies it), the shimmer band
298/// `--xui-band`; needs [`ANIMATION_PROPERTIES_CSS`].
299pub fn keyframes_css(name: &str) -> String {
300    let Some(def) = ANIMATIONS.get(name) else { return String::new() };
301    let steps: Vec<String> = def
302        .keyframes
303        .iter()
304        .map(|k| {
305            let mut decl = Vec::new();
306            if let Some(o) = k.opacity {
307                decl.push(format!("--xui-a-opacity:{}", css(o)));
308            }
309            if k.translate_x.is_some() || k.translate_y.is_some() {
310                decl.push(format!("translate:{}px {}px", css(k.translate_x.unwrap_or(0.0)), css(k.translate_y.unwrap_or(0.0))));
311            }
312            if let Some(r) = k.rotate {
313                decl.push(format!("rotate:{}deg", css(r)));
314            }
315            if let Some(s) = k.scale {
316                decl.push(format!("scale:{}", css(s)));
317            }
318            if let Some(b) = k.band {
319                decl.push(format!("--xui-band:{}", css(b)));
320            }
321            format!("{}%{{{}}}", css(k.offset * 100.0), decl.join(";"))
322        })
323        .collect();
324    format!("@keyframes xui-{name}{{{}}}", steps.join(""))
325}