1use 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#[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#[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#[derive(Debug, Clone, PartialEq, Deserialize)]
51pub struct AnimationDef {
52 pub duration: String,
53 pub factor: f64,
54 pub easing: String,
55 pub iterations: Value,
57 pub rest: f64,
59 pub keyframes: Vec<Keyframe>,
60 #[serde(default)]
61 pub band: Option<Band>,
62}
63
64pub 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
73pub const ANIMATION_NAMES: &[&str] = g::ANIMATION_NAMES;
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum AnimationEasing {
80 Bezier([f64; 4]),
81 Linear(LinearTag),
82}
83
84#[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#[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#[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#[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
147pub 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
166pub 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
177pub 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
195pub 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
258pub 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
266pub 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
279pub 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
286pub 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
291pub 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
296pub 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}