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}