Skip to main content

rustmotion_core/css/
computed.rs

1use serde_json::{Map, Value};
2
3use crate::engine::animator::AnimatedProperties;
4use crate::engine::deps::NodeRef;
5use crate::expr::{Expr, ExprError, Scope};
6
7#[derive(Debug, Clone, Default, PartialEq)]
8pub struct ComputedStyle {
9    pub opacity: Option<Expr>,
10    pub width: Option<Expr>,
11    pub height: Option<Expr>,
12    pub translate_x: Option<Expr>,
13    pub translate_y: Option<Expr>,
14    pub scale_x: Option<Expr>,
15    pub scale_y: Option<Expr>,
16    pub rotate: Option<Expr>,
17    pub node_refs: Vec<NodeRef>,
18}
19
20impl ComputedStyle {
21    pub fn is_empty(&self) -> bool {
22        self.opacity.is_none()
23            && self.width.is_none()
24            && self.height.is_none()
25            && self.translate_x.is_none()
26            && self.translate_y.is_none()
27            && self.scale_x.is_none()
28            && self.scale_y.is_none()
29            && self.rotate.is_none()
30    }
31
32    pub fn prefer(self, base: ComputedStyle) -> ComputedStyle {
33        let mut node_refs = self.node_refs;
34        node_refs.extend(base.node_refs.iter().cloned());
35        ComputedStyle {
36            opacity: self.opacity.or(base.opacity),
37            width: self.width.or(base.width),
38            height: self.height.or(base.height),
39            translate_x: self.translate_x.or(base.translate_x),
40            translate_y: self.translate_y.or(base.translate_y),
41            scale_x: self.scale_x.or(base.scale_x),
42            scale_y: self.scale_y.or(base.scale_y),
43            rotate: self.rotate.or(base.rotate),
44            node_refs,
45        }
46    }
47
48    pub fn resolve(&self, scope: &dyn Scope) -> Result<AnimatedProperties, ComputedError> {
49        let mut props = AnimatedProperties::default();
50        if let Some(e) = &self.opacity {
51            props.opacity = eval_named(e, scope, "opacity")? as f32;
52        }
53        if let Some(e) = &self.width {
54            props.width = eval_named(e, scope, "width")? as f32;
55        }
56        if let Some(e) = &self.height {
57            props.height = eval_named(e, scope, "height")? as f32;
58        }
59        if let Some(e) = &self.translate_x {
60            props.translate_x = eval_named(e, scope, "transform.translate-x.x")? as f32;
61        }
62        if let Some(e) = &self.translate_y {
63            props.translate_y = eval_named(e, scope, "transform.translate-y.y")? as f32;
64        }
65        if let Some(e) = &self.scale_x {
66            props.scale_x = eval_named(e, scope, "transform.scale.x")? as f32;
67        }
68        if let Some(e) = &self.scale_y {
69            props.scale_y = eval_named(e, scope, "transform.scale.y")? as f32;
70        }
71        if let Some(e) = &self.rotate {
72            props.rotation = eval_named(e, scope, "transform.rotate.deg")? as f32;
73        }
74        Ok(props)
75    }
76}
77
78fn eval_named(expr: &Expr, scope: &dyn Scope, property: &str) -> Result<f64, ComputedError> {
79    expr.eval(scope).map_err(|source| ComputedError {
80        property: property.to_string(),
81        source,
82    })
83}
84
85#[derive(Debug, Clone, PartialEq, thiserror::Error)]
86#[error("style.{property}: {source}")]
87pub struct ComputedError {
88    pub property: String,
89    #[source]
90    pub source: ExprError,
91}
92
93pub fn extract(obj: &mut Map<String, Value>) -> Result<ComputedStyle, ComputedError> {
94    let mut node_refs = Vec::new();
95    let mut out = ComputedStyle {
96        opacity: take_scalar(obj, "opacity", &mut node_refs)?,
97        width: take_scalar(obj, "width", &mut node_refs)?,
98        height: take_scalar(obj, "height", &mut node_refs)?,
99        ..Default::default()
100    };
101    if let Some(Value::Array(items)) = obj.get_mut("transform") {
102        for item in items.iter_mut() {
103            extract_transform_leaf(item, &mut out, &mut node_refs)?;
104        }
105    }
106    out.node_refs = node_refs;
107    Ok(out)
108}
109
110fn as_expr_source(v: &Value) -> Option<&str> {
111    match v {
112        Value::String(s) if s.trim_start().starts_with('=') => Some(s.as_str()),
113        _ => None,
114    }
115}
116
117fn take_scalar(
118    obj: &mut Map<String, Value>,
119    key: &str,
120    node_refs: &mut Vec<NodeRef>,
121) -> Result<Option<Expr>, ComputedError> {
122    let Some(src) = obj.get(key).and_then(as_expr_source) else {
123        return Ok(None);
124    };
125    let expr = Expr::parse(src).map_err(|source| ComputedError {
126        property: key.to_string(),
127        source,
128    })?;
129    node_refs.extend(crate::engine::deps::scan_node_refs(src));
130    obj.remove(key);
131    Ok(Some(expr))
132}
133
134fn extract_transform_leaf(
135    item: &mut Value,
136    out: &mut ComputedStyle,
137    node_refs: &mut Vec<NodeRef>,
138) -> Result<(), ComputedError> {
139    let Some(map) = item.as_object_mut() else {
140        return Ok(());
141    };
142    let Some(tag) = map.get("fn").and_then(Value::as_str).map(str::to_string) else {
143        return Ok(());
144    };
145    match tag.as_str() {
146        "translate" => {
147            take_leaf(map, "x", 0.0, &tag, &mut out.translate_x, node_refs)?;
148            take_leaf(map, "y", 0.0, &tag, &mut out.translate_y, node_refs)?;
149        }
150        "translate-x" => take_leaf(map, "x", 0.0, &tag, &mut out.translate_x, node_refs)?,
151        "translate-y" => take_leaf(map, "y", 0.0, &tag, &mut out.translate_y, node_refs)?,
152        "scale" => {
153            take_leaf(map, "x", 1.0, &tag, &mut out.scale_x, node_refs)?;
154            take_leaf(map, "y", 1.0, &tag, &mut out.scale_y, node_refs)?;
155        }
156        "scale-x" => take_leaf(map, "x", 1.0, &tag, &mut out.scale_x, node_refs)?,
157        "scale-y" => take_leaf(map, "y", 1.0, &tag, &mut out.scale_y, node_refs)?,
158        "rotate" => take_leaf(map, "deg", 0.0, &tag, &mut out.rotate, node_refs)?,
159        _ => {}
160    }
161    Ok(())
162}
163
164fn take_leaf(
165    map: &mut Map<String, Value>,
166    field: &str,
167    neutral: f64,
168    tag: &str,
169    slot: &mut Option<Expr>,
170    node_refs: &mut Vec<NodeRef>,
171) -> Result<(), ComputedError> {
172    let Some(src) = map.get(field).and_then(as_expr_source) else {
173        return Ok(());
174    };
175    let expr = Expr::parse(src).map_err(|source| ComputedError {
176        property: format!("transform.{tag}.{field}"),
177        source,
178    })?;
179    node_refs.extend(crate::engine::deps::scan_node_refs(src));
180    *slot = Some(expr);
181    map.insert(field.to_string(), serde_json::json!(neutral));
182    Ok(())
183}
184
185#[derive(Debug, Clone, Copy, PartialEq)]
186pub struct FrameClock {
187    pub t: f64,
188    pub t_abs: f64,
189    pub duration: f64,
190    pub width: f64,
191    pub height: f64,
192    pub fps: f64,
193}
194
195impl Scope for FrameClock {
196    fn var(&self, name: &str) -> Option<f64> {
197        match name {
198            "t" => Some(self.t),
199            "T" => Some(self.t_abs),
200            "duration" => Some(self.duration),
201            "W" => Some(self.width),
202            "H" => Some(self.height),
203            "fps" => Some(self.fps),
204            _ => None,
205        }
206    }
207}
208
209pub struct ComposedScope<'a> {
210    pub clock: FrameClock,
211    pub outer: Option<&'a dyn Scope>,
212}
213
214impl Scope for ComposedScope<'_> {
215    fn var(&self, name: &str) -> Option<f64> {
216        self.clock
217            .var(name)
218            .or_else(|| self.outer.and_then(|o| o.var(name)))
219    }
220
221    fn node_prop(&self, id: &str, prop: &str) -> Option<f64> {
222        self.outer.and_then(|o| o.node_prop(id, prop))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn obj(json: &str) -> Map<String, Value> {
231        match serde_json::from_str(json).unwrap() {
232            Value::Object(m) => m,
233            _ => panic!("not an object"),
234        }
235    }
236
237    #[test]
238    fn extracts_opacity_and_removes_the_key() {
239        let mut o = obj(r#"{ "opacity": "= $t * 2", "z-index": 3 }"#);
240        let computed = extract(&mut o).unwrap();
241        assert!(computed.opacity.is_some());
242        assert!(!o.contains_key("opacity"));
243        assert_eq!(o.get("z-index"), Some(&Value::from(3)));
244    }
245
246    #[test]
247    fn leaves_literal_opacity_untouched() {
248        let mut o = obj(r#"{ "opacity": 0.5 }"#);
249        let computed = extract(&mut o).unwrap();
250        assert!(computed.opacity.is_none());
251        assert_eq!(o.get("opacity"), Some(&Value::from(0.5)));
252    }
253
254    #[test]
255    fn width_and_height_survive_as_expressions() {
256        let mut o = obj(r#"{ "width": "= $W / 2", "height": "= $H / 2" }"#);
257        let computed = extract(&mut o).unwrap();
258        assert!(computed.width.is_some());
259        assert!(computed.height.is_some());
260        assert!(!o.contains_key("width"));
261        assert!(!o.contains_key("height"));
262    }
263
264    #[test]
265    fn transform_translate_x_extracts_and_neutralizes() {
266        let mut o = obj(r#"{ "transform": [ { "fn": "translate-x", "x": "= $t * 100" } ] }"#);
267        let computed = extract(&mut o).unwrap();
268        assert!(computed.translate_x.is_some());
269        let arr = o.get("transform").unwrap().as_array().unwrap();
270        assert_eq!(arr[0]["x"], Value::from(0.0));
271    }
272
273    #[test]
274    fn transform_scale_extracts_both_axes_and_neutralizes_to_one() {
275        let mut o =
276            obj(r#"{ "transform": [ { "fn": "scale", "x": "= 1 + $t", "y": "= 1 + $t * 2" } ] }"#);
277        let computed = extract(&mut o).unwrap();
278        assert!(computed.scale_x.is_some());
279        assert!(computed.scale_y.is_some());
280        let arr = o.get("transform").unwrap().as_array().unwrap();
281        assert_eq!(arr[0]["x"], Value::from(1.0));
282        assert_eq!(arr[0]["y"], Value::from(1.0));
283    }
284
285    #[test]
286    fn transform_rotate_extracts_deg_and_neutralizes_to_zero() {
287        let mut o = obj(r#"{ "transform": [ { "fn": "rotate", "deg": "= $t * 90" } ] }"#);
288        let computed = extract(&mut o).unwrap();
289        assert!(computed.rotate.is_some());
290        let arr = o.get("transform").unwrap().as_array().unwrap();
291        assert_eq!(arr[0]["deg"], Value::from(0.0));
292    }
293
294    #[test]
295    fn uncovered_transform_fn_is_left_completely_alone() {
296        let mut o = obj(r#"{ "transform": [ { "fn": "skew-x", "x": 12.0 } ] }"#);
297        let computed = extract(&mut o).unwrap();
298        assert!(computed.is_empty());
299        let arr = o.get("transform").unwrap().as_array().unwrap();
300        assert_eq!(arr[0]["x"], Value::from(12.0));
301    }
302
303    #[test]
304    fn a_bad_expression_is_a_named_located_parse_error() {
305        let mut o = obj(r#"{ "opacity": "= $t +" }"#);
306        let err = extract(&mut o).unwrap_err();
307        assert_eq!(err.property, "opacity");
308        assert!(matches!(err.source, ExprError::Parse { .. }));
309    }
310
311    #[test]
312    fn is_empty_is_true_for_the_default() {
313        assert!(ComputedStyle::default().is_empty());
314    }
315
316    #[test]
317    fn resolve_applies_frame_clock_and_yields_animated_properties() {
318        let mut o = obj(r#"{ "opacity": "= 0.5 + $t / 10" }"#);
319        let computed = extract(&mut o).unwrap();
320        let clock = FrameClock {
321            t: 2.0,
322            t_abs: 2.0,
323            duration: 5.0,
324            width: 1920.0,
325            height: 1080.0,
326            fps: 30.0,
327        };
328        let props = computed.resolve(&clock).unwrap();
329        assert!((props.opacity - 0.7).abs() < 1e-6);
330    }
331
332    #[test]
333    fn resolve_reports_a_named_located_error_for_an_unresolvable_scope_reference() {
334        let mut o = obj(r#"{ "opacity": "= $keyDraw" }"#);
335        let computed = extract(&mut o).unwrap();
336        let clock = FrameClock {
337            t: 0.0,
338            t_abs: 0.0,
339            duration: 1.0,
340            width: 1.0,
341            height: 1.0,
342            fps: 30.0,
343        };
344        let err = computed.resolve(&clock).unwrap_err();
345        assert_eq!(err.property, "opacity");
346        assert_eq!(err.source, ExprError::UnknownIdent("keyDraw".to_string()));
347    }
348
349    #[test]
350    fn prefer_keeps_self_over_base_per_field() {
351        let a = ComputedStyle {
352            opacity: Some(Expr::parse("= 1").unwrap()),
353            ..Default::default()
354        };
355        let b = ComputedStyle {
356            opacity: Some(Expr::parse("= 2").unwrap()),
357            width: Some(Expr::parse("= 3").unwrap()),
358            ..Default::default()
359        };
360        let merged = a.prefer(b);
361        assert_eq!(merged.opacity, Some(Expr::parse("= 1").unwrap()));
362        assert_eq!(merged.width, Some(Expr::parse("= 3").unwrap()));
363    }
364
365    #[test]
366    fn extract_collects_a_node_ref_from_a_covered_property() {
367        let mut o = obj(r#"{ "opacity": "= node(\"badge\", \"tx\")" }"#);
368        let computed = extract(&mut o).unwrap();
369        assert_eq!(
370            computed.node_refs,
371            vec![NodeRef {
372                id: "badge".to_string(),
373                prop: "tx".to_string()
374            }]
375        );
376    }
377
378    #[test]
379    fn extract_collects_node_refs_from_a_transform_leaf() {
380        let mut o =
381            obj(r#"{ "transform": [ { "fn": "translate-x", "x": "= node(\"chip\", \"tx\")" } ] }"#);
382        let computed = extract(&mut o).unwrap();
383        assert_eq!(
384            computed.node_refs,
385            vec![NodeRef {
386                id: "chip".to_string(),
387                prop: "tx".to_string()
388            }]
389        );
390    }
391
392    #[test]
393    fn extract_finds_no_node_refs_in_a_var_only_expression() {
394        let mut o = obj(r#"{ "opacity": "= $fade" }"#);
395        let computed = extract(&mut o).unwrap();
396        assert!(computed.node_refs.is_empty());
397    }
398
399    #[test]
400    fn prefer_unions_node_refs_from_both_sides() {
401        let mut a_obj = obj(r#"{ "opacity": "= node(\"a\", \"tx\")" }"#);
402        let a = extract(&mut a_obj).unwrap();
403        let mut b_obj = obj(r#"{ "width": "= node(\"b\", \"width\")" }"#);
404        let b = extract(&mut b_obj).unwrap();
405        let merged = a.prefer(b);
406        assert_eq!(merged.node_refs.len(), 2);
407        assert!(merged
408            .node_refs
409            .iter()
410            .any(|r| r.id == "a" && r.prop == "tx"));
411        assert!(merged
412            .node_refs
413            .iter()
414            .any(|r| r.id == "b" && r.prop == "width"));
415    }
416
417    struct StaticOuter;
418    impl Scope for StaticOuter {
419        fn var(&self, name: &str) -> Option<f64> {
420            match name {
421                "fade" => Some(0.25),
422                "t" => Some(999.0),
423                _ => None,
424            }
425        }
426        fn node_prop(&self, id: &str, prop: &str) -> Option<f64> {
427            match (id, prop) {
428                ("badge", "tx") => Some(42.0),
429                _ => None,
430            }
431        }
432    }
433
434    #[test]
435    fn composed_scope_prefers_the_clocks_reserved_names() {
436        let clock = FrameClock {
437            t: 1.0,
438            t_abs: 1.0,
439            duration: 1.0,
440            width: 1.0,
441            height: 1.0,
442            fps: 30.0,
443        };
444        let composed = ComposedScope {
445            clock,
446            outer: Some(&StaticOuter),
447        };
448        assert_eq!(composed.var("t"), Some(1.0));
449    }
450
451    #[test]
452    fn composed_scope_falls_back_to_the_outer_scope_for_vars_and_node_refs() {
453        let clock = FrameClock {
454            t: 1.0,
455            t_abs: 1.0,
456            duration: 1.0,
457            width: 1.0,
458            height: 1.0,
459            fps: 30.0,
460        };
461        let composed = ComposedScope {
462            clock,
463            outer: Some(&StaticOuter),
464        };
465        assert_eq!(composed.var("fade"), Some(0.25));
466        assert_eq!(composed.node_prop("badge", "tx"), Some(42.0));
467        assert_eq!(composed.node_prop("nope", "tx"), None);
468    }
469
470    #[test]
471    fn composed_scope_with_no_outer_behaves_exactly_like_a_bare_frame_clock() {
472        let clock = FrameClock {
473            t: 3.0,
474            t_abs: 4.0,
475            duration: 5.0,
476            width: 6.0,
477            height: 7.0,
478            fps: 30.0,
479        };
480        let composed = ComposedScope { clock, outer: None };
481        assert_eq!(composed.var("t"), clock.var("t"));
482        assert_eq!(composed.var("W"), clock.var("W"));
483        assert_eq!(composed.var("unknown"), clock.var("unknown"));
484        assert_eq!(composed.node_prop("x", "y"), None);
485    }
486}