1use std::sync::Arc;
2
3use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize};
4use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP};
5use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
6use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId};
7use rustmotion_core::engine::deps::NodeRef;
8use rustmotion_core::expr::Scope;
9use rustmotion_core::schema::video::{AnimationEffect, MotionBlurConfig, TrailConfig};
10
11use crate::callout::ArrowDirection as CalloutArrowDirection;
12use crate::chart::ChartType;
13use crate::divider::DividerDirection;
14use crate::mockup::MockupDevice;
15use crate::skeleton::SkeletonVariant;
16use crate::stepper::StepperOrientation;
17use crate::timeline::TimelineDirection;
18use crate::tooltip::TooltipArrow;
19use crate::{ChildComponent, Component};
20
21#[derive(Debug, Clone, Copy)]
22pub struct BuildAnimationCtx {
23 pub time: f64,
24 pub scenario_time: f64,
25 pub scene_duration: f64,
26 pub fps: u32,
27}
28
29const ARROW_BBOX_PADDING: f32 = 16.0;
30
31pub struct BuiltScene<'a> {
32 pub root: BoxNode,
33 pub components: Vec<Option<&'a ChildComponent>>,
34 pub stagger_delays: Vec<f64>,
35 pub time_params: Vec<(f64, f64)>,
36 pub ghost_principal: Vec<(NodeId, NodeId)>,
37}
38
39pub fn build_scene<'a>(children: &'a [ChildComponent], viewport: (f32, f32)) -> BuiltScene<'a> {
40 build_scene_with_root(children, viewport, default_root_css(viewport))
41}
42
43pub fn build_scene_with_root<'a>(
44 children: &'a [ChildComponent],
45 viewport: (f32, f32),
46 root_css: CssStyle,
47) -> BuiltScene<'a> {
48 build_scene_from_refs(children.iter(), viewport, root_css, None)
49}
50
51pub fn build_scene_at_time<'a>(
52 children: &'a [ChildComponent],
53 viewport: (f32, f32),
54 root_css: CssStyle,
55 anim: BuildAnimationCtx,
56) -> BuiltScene<'a> {
57 build_scene_from_refs(children.iter(), viewport, root_css, Some(anim))
58}
59
60pub fn build_scene_with_anim<'a>(
61 children: &'a [ChildComponent],
62 viewport: (f32, f32),
63 anim: BuildAnimationCtx,
64) -> BuiltScene<'a> {
65 build_scene_from_refs(
66 children.iter(),
67 viewport,
68 default_root_css(viewport),
69 Some(anim),
70 )
71}
72
73pub fn build_scene_from_refs<'a, I>(
74 children: I,
75 viewport: (f32, f32),
76 root_css: CssStyle,
77 anim: Option<BuildAnimationCtx>,
78) -> BuiltScene<'a>
79where
80 I: IntoIterator<Item = &'a ChildComponent>,
81{
82 build_scene_from_refs_with_scope(children, viewport, root_css, anim, None)
83}
84
85pub fn build_scene_from_refs_with_scope<'a, I>(
86 children: I,
87 viewport: (f32, f32),
88 root_css: CssStyle,
89 anim: Option<BuildAnimationCtx>,
90 outer_scope: Option<&dyn Scope>,
91) -> BuiltScene<'a>
92where
93 I: IntoIterator<Item = &'a ChildComponent>,
94{
95 build_scene_from_refs_with_scope_quiet(children, viewport, root_css, anim, outer_scope, true)
96}
97
98pub fn build_scene_from_refs_with_scope_quiet<'a, I>(
99 children: I,
100 viewport: (f32, f32),
101 mut root_css: CssStyle,
102 anim: Option<BuildAnimationCtx>,
103 outer_scope: Option<&dyn Scope>,
104 warn_unresolved: bool,
105) -> BuiltScene<'a>
106where
107 I: IntoIterator<Item = &'a ChildComponent>,
108{
109 let mut components: Vec<Option<&'a ChildComponent>> = vec![None];
110 let mut stagger_delays: Vec<f64> = vec![0.0];
111 let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)];
112 let mut ghost_principal: Vec<(NodeId, NodeId)> = Vec::new();
113 let mut next_id: NodeId = 1;
114
115 let mut child_boxes = Vec::new();
116 for (i, c) in children.into_iter().enumerate() {
117 child_boxes.extend(build_child(
118 c,
119 &mut components,
120 &mut stagger_delays,
121 &mut time_params,
122 &mut ghost_principal,
123 &mut next_id,
124 anim,
125 format!("/children/{i}"),
126 0.0,
127 (1.0, 0.0),
128 &root_css,
129 viewport,
130 outer_scope,
131 warn_unresolved,
132 ));
133 }
134
135 root_css.width = Some(CSize::Length(CLP::Px(viewport.0)));
136 root_css.height = Some(CSize::Length(CLP::Px(viewport.1)));
137
138 let root = BoxNode {
139 id: 0,
140 kind: BoxKind::Container,
141 css: root_css,
142 children: child_boxes,
143 intrinsic: None,
144 source_path: None,
145 window: None,
146 };
147
148 BuiltScene {
149 root,
150 components,
151 stagger_delays,
152 time_params,
153 ghost_principal,
154 }
155}
156
157pub fn collect_node_refs<'a, I>(children: I) -> Vec<(String, Vec<NodeRef>)>
158where
159 I: IntoIterator<Item = &'a ChildComponent>,
160{
161 let mut out = Vec::new();
162 for child in children {
163 collect_node_refs_into(child, &mut out);
164 }
165 out
166}
167
168fn collect_node_refs_into(child: &ChildComponent, out: &mut Vec<(String, Vec<NodeRef>)>) {
169 if let Some(id) = &child.id {
170 out.push((
171 id.clone(),
172 component_style(&child.component).expr.node_refs.clone(),
173 ));
174 }
175 for c in container_children_of(&child.component) {
176 collect_node_refs_into(c, out);
177 }
178}
179
180fn container_children_of(component: &Component) -> &[ChildComponent] {
181 match component {
182 Component::Container(c) => &c.children,
183 _ => &[],
184 }
185}
186
187pub fn scene_uses_node_refs<'a, I>(children: I) -> bool
188where
189 I: IntoIterator<Item = &'a ChildComponent>,
190{
191 children.into_iter().any(scene_uses_node_refs_in)
192}
193
194fn scene_uses_node_refs_in(child: &ChildComponent) -> bool {
195 if !component_style(&child.component).expr.node_refs.is_empty() {
196 return true;
197 }
198 container_children_of(&child.component)
199 .iter()
200 .any(scene_uses_node_refs_in)
201}
202
203fn default_root_css(viewport: (f32, f32)) -> CssStyle {
204 CssStyle {
205 display: Some(rustmotion_core::css::style::Display::Flex),
206 flex_direction: Some(rustmotion_core::css::style::FlexDirection::Column),
207 width: Some(CSize::Length(CLP::Px(viewport.0))),
208 height: Some(CSize::Length(CLP::Px(viewport.1))),
209 ..Default::default()
210 }
211}
212
213fn detect_ghost_effects(
214 effects: &[AnimationEffect],
215) -> (Option<MotionBlurConfig>, Option<TrailConfig>) {
216 let mut mb: Option<MotionBlurConfig> = None;
217 let mut tr: Option<TrailConfig> = None;
218 for e in effects {
219 match e {
220 AnimationEffect::MotionBlur(c) if mb.is_none() => mb = Some(c.clone()),
221 AnimationEffect::Trail(c) if tr.is_none() => tr = Some(c.clone()),
222 _ => {}
223 }
224 }
225 (mb, tr)
226}
227
228fn ghost_css_for(
229 child: &ChildComponent,
230 parent_css: &CssStyle,
231 extra_delay: f64,
232 scene_duration: f64,
233 ghost_time: f64,
234 ghost_opacity_scale: f32,
235) -> CssStyle {
236 let mut css = component_css(&child.component);
237 css.position = Some(Position::Absolute);
238 if let Some((x, y)) = child.absolute_position() {
239 css.left = Some(CLP::Px(x));
240 css.top = Some(CLP::Px(y));
241 }
242 if let Some(z) = child.z_index {
243 css.z_index = Some(z);
244 }
245 rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
246 if let Some(animatable) = child.component.as_animatable() {
247 let steps = animatable.timeline_steps();
248 if steps.iter().any(|s| s.style.is_some()) {
249 let skip_opacity = css.transition.is_some();
250 apply_style_states(&mut css, steps, ghost_time - extra_delay, skip_opacity);
251 let overrides = resolve_transition_css_overrides(
252 child.component.as_styled().style_config(),
253 steps,
254 ghost_time - extra_delay,
255 );
256 if let Some(br) = overrides.border_radius {
257 css.border_radius = Some(br);
258 }
259 if let Some(bg) = overrides.background {
260 css.background = Some(bg);
261 }
262 }
263 }
264 if let Some(ghost_effects) = effective_effects(&child.component, extra_delay, ghost_time) {
265 let props = resolve_props_for_effects(&ghost_effects, ghost_time, scene_duration);
266 apply_animated_props(&mut css, &props);
267 apply_glow_effect(&mut css, &ghost_effects);
268 carry_paint_pass_effects(&mut css, &ghost_effects);
269 apply_directional_blur_props(&mut css, &props);
270 }
271 apply_pointer_path_transform(&mut css, &child.component, ghost_time);
272 let base_opacity = css.opacity.unwrap_or(1.0);
273 css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0));
274 css
275}
276
277#[allow(clippy::too_many_arguments)]
278fn build_one_ghost<'a>(
279 child: &'a ChildComponent,
280 components: &mut Vec<Option<&'a ChildComponent>>,
281 stagger_delays: &mut Vec<f64>,
282 time_params: &mut Vec<(f64, f64)>,
283 ghost_principal: &mut Vec<(NodeId, NodeId)>,
284 next_id: &mut NodeId,
285 anim: Option<BuildAnimationCtx>,
286 actx: BuildAnimationCtx,
287 stagger_delay: f64,
288 extra_delay: f64,
289 time_remap: (f64, f64),
290 parent_css: &CssStyle,
291 path: &str,
292 viewport: (f32, f32),
293 outer_scope: Option<&dyn Scope>,
294 warn_unresolved: bool,
295 offset: f64,
296 ghost_opacity_scale: f32,
297) -> BoxNode {
298 let ghost_time = actx.time - offset;
299 let ghost_time_remap = (time_remap.0, time_remap.1 - offset);
300 let ghost_css = ghost_css_for(
301 child,
302 parent_css,
303 extra_delay,
304 actx.scene_duration,
305 ghost_time,
306 ghost_opacity_scale,
307 );
308 let ghost_intrinsic = component_intrinsic(&child.component, &ghost_css);
309
310 let ghost_id = *next_id;
311 *next_id += 1;
312 components.push(Some(child));
313 stagger_delays.push(extra_delay);
314 time_params.push(ghost_time_remap);
315
316 let ghost_children = container_children(
317 &child.component,
318 components,
319 stagger_delays,
320 time_params,
321 ghost_principal,
322 next_id,
323 anim,
324 path,
325 stagger_delay,
326 ghost_time_remap,
327 &ghost_css,
328 viewport,
329 outer_scope,
330 warn_unresolved,
331 );
332
333 BoxNode {
334 id: ghost_id,
335 kind: BoxKind::Ghost(Arc::new(ghost_id)),
336 css: ghost_css,
337 children: ghost_children,
338 intrinsic: ghost_intrinsic,
339 source_path: None,
340 window: None,
341 }
342}
343
344#[allow(clippy::too_many_arguments)]
345fn build_ghosts<'a>(
346 child: &'a ChildComponent,
347 components: &mut Vec<Option<&'a ChildComponent>>,
348 stagger_delays: &mut Vec<f64>,
349 time_params: &mut Vec<(f64, f64)>,
350 ghost_principal: &mut Vec<(NodeId, NodeId)>,
351 next_id: &mut NodeId,
352 anim: Option<BuildAnimationCtx>,
353 actx: BuildAnimationCtx,
354 stagger_delay: f64,
355 extra_delay: f64,
356 time_remap: (f64, f64),
357 effects: &[AnimationEffect],
358 parent_css: &CssStyle,
359 path: &str,
360 viewport: (f32, f32),
361 outer_scope: Option<&dyn Scope>,
362 warn_unresolved: bool,
363) -> Vec<BoxNode> {
364 let (mb, tr) = detect_ghost_effects(effects);
365
366 enum Strategy {
367 MotionBlur {
368 samples: u32,
369 shutter_window: f64,
370 },
371 Trail {
372 copies: u32,
373 spacing: f64,
374 falloff: f32,
375 },
376 }
377 let strategy = if let Some(mc) = mb {
378 if mc.mode == rustmotion_core::schema::MotionBlurMode::Smear {
379 return Vec::new();
380 }
381 let samples = mc.samples.clamp(1, 16);
382 if samples <= 1 {
383 return Vec::new();
384 }
385 let shutter_window = mc.shutter / actx.fps.max(1) as f64;
386 Strategy::MotionBlur {
387 samples,
388 shutter_window,
389 }
390 } else if let Some(tc) = tr {
391 let copies = tc.copies.clamp(1, 12);
392 Strategy::Trail {
393 copies,
394 spacing: tc.spacing,
395 falloff: tc.falloff,
396 }
397 } else {
398 return Vec::new();
399 };
400
401 let mut ghosts = Vec::new();
402
403 match strategy {
404 Strategy::MotionBlur {
405 samples,
406 shutter_window,
407 } => {
408 let ghost_opacity_scale = 1.0 / (samples + 1) as f32;
409 for i in 1..=samples {
410 let offset = i as f64 * shutter_window / samples as f64;
411 ghosts.push(build_one_ghost(
412 child,
413 components,
414 stagger_delays,
415 time_params,
416 ghost_principal,
417 next_id,
418 anim,
419 actx,
420 stagger_delay,
421 extra_delay,
422 time_remap,
423 parent_css,
424 path,
425 viewport,
426 outer_scope,
427 warn_unresolved,
428 offset,
429 ghost_opacity_scale,
430 ));
431 }
432 }
433 Strategy::Trail {
434 copies,
435 spacing,
436 falloff,
437 } => {
438 let mut trail_nodes = Vec::with_capacity(copies as usize);
439 for i in 1..=copies {
440 let offset = i as f64 * spacing;
441 let ghost_opacity_scale = falloff.powi(i as i32);
442 trail_nodes.push(build_one_ghost(
443 child,
444 components,
445 stagger_delays,
446 time_params,
447 ghost_principal,
448 next_id,
449 anim,
450 actx,
451 stagger_delay,
452 extra_delay,
453 time_remap,
454 parent_css,
455 path,
456 viewport,
457 outer_scope,
458 warn_unresolved,
459 offset,
460 ghost_opacity_scale,
461 ));
462 }
463 trail_nodes.reverse();
464 ghosts = trail_nodes;
465 }
466 }
467
468 ghosts
469}
470
471#[allow(clippy::too_many_arguments)]
472fn build_child<'a>(
473 child: &'a ChildComponent,
474 components: &mut Vec<Option<&'a ChildComponent>>,
475 stagger_delays: &mut Vec<f64>,
476 time_params: &mut Vec<(f64, f64)>,
477 ghost_principal: &mut Vec<(NodeId, NodeId)>,
478 next_id: &mut NodeId,
479 anim: Option<BuildAnimationCtx>,
480 path: String,
481 stagger_delay: f64,
482 time_remap: (f64, f64),
483 parent_css: &CssStyle,
484 viewport: (f32, f32),
485 outer_scope: Option<&dyn Scope>,
486 warn_unresolved: bool,
487) -> Vec<BoxNode> {
488 let local_actx = anim.map(|a| {
489 let (scale, shift) = time_remap;
490 BuildAnimationCtx {
491 time: a.time * scale + shift,
492 scenario_time: a.scenario_time,
493 scene_duration: a.scene_duration,
494 fps: a.fps,
495 }
496 });
497
498 let anim_delay = stagger_delay
499 + child
500 .component
501 .as_timed()
502 .and_then(|t| t.timing().0)
503 .unwrap_or(0.0);
504
505 let mut ghosts: Vec<BoxNode> = Vec::new();
506 if let Some(actx) = local_actx {
507 if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) {
508 ghosts = build_ghosts(
509 child,
510 components,
511 stagger_delays,
512 time_params,
513 ghost_principal,
514 next_id,
515 anim,
516 actx,
517 stagger_delay,
518 anim_delay,
519 time_remap,
520 &effects,
521 parent_css,
522 &path,
523 viewport,
524 outer_scope,
525 warn_unresolved,
526 );
527 }
528 }
529
530 let id = *next_id;
531 *next_id += 1;
532 components.push(Some(child));
533 stagger_delays.push(anim_delay);
534 time_params.push(time_remap);
535 for g in &ghosts {
536 ghost_principal.push((g.id, id));
537 }
538
539 let mut css = component_css(&child.component);
540
541 if let Some((x, y)) = child.absolute_position() {
542 css.position = Some(Position::Absolute);
543 css.left = Some(CLP::Px(x));
544 css.top = Some(CLP::Px(y));
545 }
546 if let Some(z) = child.z_index {
547 css.z_index = Some(z);
548 }
549
550 rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
551
552 if let Some(animatable) = child.component.as_animatable() {
553 let steps = animatable.timeline_steps();
554 if steps.iter().any(|s| s.style.is_some()) {
555 let t = local_actx.map(|a| a.time).unwrap_or(0.0);
556 let skip_opacity = css.transition.is_some();
557 apply_style_states(&mut css, steps, t - anim_delay, skip_opacity);
558 let overrides = resolve_transition_css_overrides(
559 child.component.as_styled().style_config(),
560 steps,
561 t - anim_delay,
562 );
563 if let Some(br) = overrides.border_radius {
564 css.border_radius = Some(br);
565 }
566 if let Some(bg) = overrides.background {
567 css.background = Some(bg);
568 }
569 }
570 }
571
572 if let Some(actx) = local_actx {
573 if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) {
574 let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration);
575 apply_animated_props(&mut css, &props);
576 apply_glow_effect(&mut css, &effects);
577 carry_paint_pass_effects(&mut css, &effects);
578 apply_directional_blur_props(&mut css, &props);
579 apply_motion_blur_smear(
580 &mut css,
581 &effects,
582 &child.component,
583 anim_delay,
584 actx.time,
585 actx.scene_duration,
586 actx.fps,
587 );
588 }
589 apply_pointer_path_transform(&mut css, &child.component, actx.time);
590 resolve_computed_style(
591 &mut css,
592 &path,
593 actx,
594 viewport,
595 outer_scope,
596 warn_unresolved,
597 );
598 }
599
600 if let Some(ar) = css.audio_reactive.take() {
601 use rustmotion_core::css::style::{AudioReactiveProperty, AudioSource, AudioSourceTag};
602 use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache;
603
604 if let Some(actx) = local_actx {
605 let cache = audio_analysis_cache();
606 let analysis_opt = if let Some(ref src) = ar.track {
607 cache.get(src).map(|r| r.clone())
608 } else {
609 cache.iter().next().map(|r| r.value().clone())
610 };
611
612 let raw = if let Some(analysis) = analysis_opt {
613 match &ar.source {
614 AudioSource::Amplitude(AudioSourceTag::Amplitude) => {
615 analysis.amplitude_smoothed(actx.scenario_time, ar.smoothing_frames)
616 }
617 AudioSource::Band { band } => {
618 analysis.band_smoothed(actx.scenario_time, *band, ar.smoothing_frames)
619 }
620 }
621 } else {
622 0.0
623 };
624
625 let lerped = ar.min as f32 + raw * (ar.max - ar.min) as f32;
626
627 match ar.property {
628 AudioReactiveProperty::Opacity => {
629 let base = css.opacity.unwrap_or(1.0);
630 css.opacity = Some(base * lerped.clamp(0.0, 1.0));
631 }
632 AudioReactiveProperty::Scale => {
633 let s = lerped.max(0.0);
634 let tx = css.transform.get_or_insert_with(Vec::new);
635 tx.push(rustmotion_core::css::style::TransformFn::Scale { x: s, y: s });
636 }
637 AudioReactiveProperty::TranslateY => {
638 use rustmotion_core::css::units::LengthPercentage;
639 let tx = css.transform.get_or_insert_with(Vec::new);
640 tx.push(rustmotion_core::css::style::TransformFn::TranslateY {
641 y: LengthPercentage::Px(lerped),
642 });
643 }
644 AudioReactiveProperty::Rotation => {
645 let tx = css.transform.get_or_insert_with(Vec::new);
646 tx.push(rustmotion_core::css::style::TransformFn::Rotate { deg: lerped });
647 }
648 }
649 }
650 }
651
652 let window = child.component.as_timed().and_then(|t| {
653 let (start, end) = t.timing();
654 (start.is_some() || end.is_some()).then_some({
655 let (scale, shift) = time_remap;
656 let to_global = |t_local: f64| -> f64 {
657 if scale.abs() < 1e-10 {
658 t_local
659 } else {
660 (t_local - shift) / scale
661 }
662 };
663 rustmotion_core::engine::box_tree::PaintWindow {
664 start: start.map(|s| to_global(s + stagger_delay)),
665 end: end.map(|e| to_global(e + stagger_delay)),
666 }
667 })
668 });
669
670 let children_boxes = container_children(
671 &child.component,
672 components,
673 stagger_delays,
674 time_params,
675 ghost_principal,
676 next_id,
677 anim,
678 &path,
679 stagger_delay,
680 time_remap,
681 &css,
682 viewport,
683 outer_scope,
684 warn_unresolved,
685 );
686 let intrinsic = component_intrinsic(&child.component, &css);
687
688 let principal = BoxNode {
689 id,
690 kind: BoxKind::Component(Arc::new(id)),
691 css,
692 children: children_boxes,
693 intrinsic,
694 source_path: Some(path),
695 window,
696 };
697
698 let mut result = ghosts;
699 result.push(principal);
700 result
701}
702
703fn resolve_computed_style(
704 css: &mut CssStyle,
705 path: &str,
706 actx: BuildAnimationCtx,
707 viewport: (f32, f32),
708 outer_scope: Option<&dyn Scope>,
709 warn_unresolved: bool,
710) {
711 if css.expr.is_empty() {
712 return;
713 }
714 let clock = rustmotion_core::css::FrameClock {
715 t: actx.time,
716 t_abs: actx.scenario_time,
717 duration: actx.scene_duration,
718 width: viewport.0 as f64,
719 height: viewport.1 as f64,
720 fps: actx.fps as f64,
721 };
722 let scope = rustmotion_core::css::ComposedScope {
723 clock,
724 outer: outer_scope,
725 };
726 match css.expr.resolve(&scope) {
727 Ok(props) => apply_animated_props(css, &props),
728 Err(e) => {
729 if warn_unresolved {
730 eprintln!(
731 "warning: {path}: {e} — this property keeps its pre-expression value this frame"
732 );
733 }
734 }
735 }
736}
737
738pub fn effective_effects(
739 component: &Component,
740 extra_delay: f64,
741 t: f64,
742) -> Option<std::borrow::Cow<'_, [rustmotion_core::schema::AnimationEffect]>> {
743 let animatable = component.as_animatable()?;
744 let effects = animatable.animation_effects();
745 let steps = animatable.timeline_steps();
746 let smooth_color = matches!(component, Component::Text(_) | Component::Counter(_));
747 let synthesized =
748 transition_keyframes(component.as_styled().style_config(), steps, smooth_color);
749 if steps.is_empty() && synthesized.is_empty() && extra_delay == 0.0 {
750 return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects));
751 }
752 let mut merged = effects.to_vec();
753 for step in steps.iter().filter(|s| s.at <= t - extra_delay) {
754 for effect in &step.animation {
755 let mut e = effect.clone();
756 e.shift_delay(step.at);
757 merged.push(e);
758 }
759 }
760 merged.extend(synthesized);
761 if extra_delay != 0.0 {
762 for e in &mut merged {
763 e.shift_delay(extra_delay);
764 }
765 }
766 (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged))
767}
768
769pub(crate) fn apply_style_states(
770 css: &mut CssStyle,
771 steps: &[rustmotion_core::schema::TimelineStep],
772 t: f64,
773 skip_opacity: bool,
774) {
775 let mut due: Vec<&rustmotion_core::schema::TimelineStep> = steps
776 .iter()
777 .filter(|s| s.style.is_some() && s.at <= t)
778 .collect();
779 if due.is_empty() {
780 return;
781 }
782 due.sort_by(|a, b| a.at.total_cmp(&b.at));
783
784 let Ok(serde_json::Value::Object(mut base)) = serde_json::to_value(&*css) else {
785 return;
786 };
787 for step in due {
788 let Some(style) = step.style.as_deref() else {
789 continue;
790 };
791 let Ok(serde_json::Value::Object(state)) = serde_json::to_value(style) else {
792 continue;
793 };
794 for (k, v) in state {
795 if v.is_null() {
796 continue;
797 }
798 if k == "animation" && v.as_array().is_some_and(|a| a.is_empty()) {
799 continue;
800 }
801 if skip_opacity && k == "opacity" {
802 continue;
803 }
804 base.insert(k, v);
805 }
806 }
807 let saved_expr = css.expr.clone();
808 if let Ok(mut merged) = serde_json::from_value::<CssStyle>(serde_json::Value::Object(base)) {
809 merged.expr = merged.expr.prefer(saved_expr);
810 *css = merged;
811 }
812}
813
814pub(crate) fn transition_keyframes(
815 base: &CssStyle,
816 steps: &[rustmotion_core::schema::TimelineStep],
817 smooth_color: bool,
818) -> Vec<rustmotion_core::schema::AnimationEffect> {
819 use rustmotion_core::schema::{
820 Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig,
821 };
822
823 let has_states = steps.iter().any(|s| s.style.is_some());
824 if !has_states {
825 return Vec::new();
826 }
827 let (duration, easing) = match base.transition.as_ref() {
828 Some(tr) if tr.duration() > 0.0 => (tr.duration(), tr.easing()),
829 _ => (0.001, rustmotion_core::schema::EasingType::Linear),
830 };
831 let smooth_opacity = base.transition.is_some();
832
833 let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
834 steps.iter().filter(|s| s.style.is_some()).collect();
835 sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
836
837 let base_opacity = base.opacity.unwrap_or(1.0) as f64;
838 let mut opacity_kfs: Vec<Keyframe> = Vec::new();
839 let mut color_kfs: Vec<Keyframe> = Vec::new();
840 let mut prev_opacity = base_opacity;
841 let mut prev_color = base.color.as_ref().map(|c| c.to_css_string());
842
843 let kf_num = |time: f64, v: f64| Keyframe {
844 time,
845 value: KeyframeValue::Number(v),
846 easing: None,
847 };
848 let kf_color = |time: f64, c: String| Keyframe {
849 time,
850 value: KeyframeValue::Color(c),
851 easing: None,
852 };
853 let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
854 let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
855 let start = at.max(floor);
856 let mut from = from;
857 let mut to = to;
858 from.time = start;
859 to.time = to.time.max(start + 1e-6);
860 kfs.push(from);
861 kfs.push(to);
862 };
863
864 for step in sorted {
865 let style = step.style.as_deref().unwrap();
866 if smooth_opacity && base_opacity > 1e-6 {
867 if let Some(o) = style.opacity {
868 let target = o as f64;
869 if (target - prev_opacity).abs() > 1e-6 {
870 push_pair(
871 &mut opacity_kfs,
872 step.at,
873 kf_num(step.at, prev_opacity / base_opacity),
874 kf_num(step.at + duration, target / base_opacity),
875 );
876 prev_opacity = target;
877 }
878 }
879 }
880 if smooth_color {
881 if let Some(c) = style.color.as_ref() {
882 let target = c.to_css_string();
883 if prev_color.as_ref() != Some(&target) {
884 if let Some(from) = prev_color.clone() {
885 push_pair(
886 &mut color_kfs,
887 step.at,
888 kf_color(step.at, from),
889 kf_color(step.at + duration, target.clone()),
890 );
891 }
892 prev_color = Some(target);
893 }
894 }
895 }
896 }
897
898 let mut out = Vec::new();
899 let mut push_effect = |property: &str, keyframes: Vec<Keyframe>| {
900 if keyframes.is_empty() {
901 return;
902 }
903 out.push(AnimationEffect::Keyframes(KeyframesConfig {
904 keyframes: vec![Animation {
905 property: property.to_string(),
906 keyframes,
907 easing: easing.clone(),
908 spring: None,
909 }],
910 delay: 0.0,
911 duration: 0.0,
912 repeat: false,
913 }));
914 };
915 push_effect("opacity", opacity_kfs);
916 push_effect("color", color_kfs);
917 out
918}
919
920pub(crate) struct TransitionCssOverrides {
921 pub border_radius: Option<rustmotion_core::css::style::BorderRadius>,
922 pub background: Option<rustmotion_core::css::style::Background>,
923}
924
925pub(crate) fn resolve_transition_css_overrides(
926 base: &CssStyle,
927 steps: &[rustmotion_core::schema::TimelineStep],
928 t: f64,
929) -> TransitionCssOverrides {
930 use rustmotion_core::css::style::{Background, BorderRadius, Color};
931 use rustmotion_core::css::units::LengthPercentage as CssLP;
932 use rustmotion_core::engine::animator::resolve_keyframe_track;
933 use rustmotion_core::schema::{Animation, Keyframe, KeyframeValue};
934
935 let mut out = TransitionCssOverrides {
936 border_radius: None,
937 background: None,
938 };
939 let Some(tr) = base.transition.as_ref() else {
940 return out;
941 };
942 if tr.duration() <= 0.0 {
943 return out;
944 }
945 let duration = tr.duration();
946 let easing = tr.easing();
947
948 let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
949 steps.iter().filter(|s| s.style.is_some()).collect();
950 sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
951
952 let mut prev_radius = base
953 .border_radius
954 .as_ref()
955 .and_then(BorderRadius::absolute_px);
956 let mut prev_bg = base.background.as_ref().and_then(Background::solid_hex);
957 let mut radius_kfs: Vec<Keyframe> = Vec::new();
958 let mut bg_kfs: Vec<Keyframe> = Vec::new();
959
960 let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
961 let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
962 let start = at.max(floor);
963 let mut from = from;
964 let mut to = to;
965 from.time = start;
966 to.time = to.time.max(start + 1e-6);
967 kfs.push(from);
968 kfs.push(to);
969 };
970
971 for step in sorted {
972 let style = step.style.as_deref().unwrap();
973 if let Some(br) = style.border_radius.as_ref() {
974 match br.absolute_px() {
975 Some(target) => {
976 if prev_radius != Some(target) {
977 if let Some(from) = prev_radius {
978 push_pair(
979 &mut radius_kfs,
980 step.at,
981 Keyframe {
982 time: step.at,
983 value: KeyframeValue::Number(from as f64),
984 easing: None,
985 },
986 Keyframe {
987 time: step.at + duration,
988 value: KeyframeValue::Number(target as f64),
989 easing: None,
990 },
991 );
992 }
993 prev_radius = Some(target);
994 }
995 }
996 None => prev_radius = None,
997 }
998 }
999 if let Some(bg) = style.background.as_ref() {
1000 match bg.solid_hex() {
1001 Some(target) => {
1002 if prev_bg.as_deref() != Some(target.as_str()) {
1003 if let Some(from) = prev_bg.clone() {
1004 push_pair(
1005 &mut bg_kfs,
1006 step.at,
1007 Keyframe {
1008 time: step.at,
1009 value: KeyframeValue::Color(from),
1010 easing: None,
1011 },
1012 Keyframe {
1013 time: step.at + duration,
1014 value: KeyframeValue::Color(target.clone()),
1015 easing: None,
1016 },
1017 );
1018 }
1019 prev_bg = Some(target);
1020 }
1021 }
1022 None => prev_bg = None,
1023 }
1024 }
1025 }
1026
1027 if !radius_kfs.is_empty() {
1028 let anim = Animation {
1029 property: "border_radius".to_string(),
1030 keyframes: radius_kfs,
1031 easing: easing.clone(),
1032 spring: None,
1033 };
1034 if let KeyframeValue::Number(v) = resolve_keyframe_track(&anim, t) {
1035 out.border_radius = Some(BorderRadius::Uniform(CssLP::Px(v as f32)));
1036 }
1037 }
1038 if !bg_kfs.is_empty() {
1039 let anim = Animation {
1040 property: "background".to_string(),
1041 keyframes: bg_kfs,
1042 easing,
1043 spring: None,
1044 };
1045 if let KeyframeValue::Color(c) = resolve_keyframe_track(&anim, t) {
1046 out.background = Some(Background::Color(Color::String(c)));
1047 }
1048 }
1049 out
1050}
1051
1052fn carry_paint_pass_effects(
1053 css: &mut CssStyle,
1054 effects: &[rustmotion_core::schema::AnimationEffect],
1055) {
1056 use rustmotion_core::schema::AnimationEffect;
1057 if effects
1058 .iter()
1059 .any(|e| matches!(e, AnimationEffect::Shimmer(_)))
1060 {
1061 css.animation = effects
1062 .iter()
1063 .filter(|e| matches!(e, AnimationEffect::Shimmer(_)))
1064 .cloned()
1065 .collect();
1066 }
1067}
1068
1069fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::AnimationEffect]) {
1070 use rustmotion_core::css::style::{Color, FilterFn};
1071 use rustmotion_core::css::units::Length;
1072 use rustmotion_core::engine::animator::find_glow_effect;
1073
1074 let Some(glow) = find_glow_effect(effects) else {
1075 return;
1076 };
1077
1078 let (r, g, b, a) = rustmotion_core::engine::renderer::parse_css_color(&glow.color)
1079 .unwrap_or((255, 255, 255, 255));
1080 let alpha = ((a as f32 / 255.0) * glow.intensity.max(0.0)).clamp(0.0, 1.0);
1081 let radius = glow.radius.max(0.0);
1082 if radius <= 0.0 || alpha <= 0.0 {
1083 return;
1084 }
1085
1086 let shadow = FilterFn::DropShadow {
1087 offset_x: Length::Px(0.0),
1088 offset_y: Length::Px(0.0),
1089 blur: Some(Length::Px(radius)),
1090 color: Some(Color::Rgba { r, g, b, a: alpha }),
1091 };
1092 css.filter.get_or_insert_with(Vec::new).push(shadow);
1093}
1094
1095fn apply_pointer_path_transform(css: &mut CssStyle, component: &Component, time: f64) {
1096 use rustmotion_core::css::style::TransformFn;
1097 use rustmotion_core::css::units::LengthPercentage as CssLP;
1098
1099 let Component::Pointer(p) = component else {
1100 return;
1101 };
1102 if p.path.is_empty() {
1103 return;
1104 }
1105 let (dx, dy) = crate::cursor::waypoint_offset(&p.path, time, p.click_duration, p.path_easing);
1106 css.transform
1107 .get_or_insert_with(Vec::new)
1108 .push(TransformFn::Translate {
1109 x: CssLP::Px(dx),
1110 y: CssLP::Px(dy),
1111 });
1112}
1113
1114fn apply_directional_blur_props(css: &mut CssStyle, props: &AnimatedProperties) {
1115 use rustmotion_core::css::style::FilterFn;
1116 use rustmotion_core::css::units::Length;
1117
1118 if props.blur_x <= 0.0 && props.blur_y <= 0.0 {
1119 return;
1120 }
1121 css.filter
1122 .get_or_insert_with(Vec::new)
1123 .push(FilterFn::Blur {
1124 radius: None,
1125 radius_x: (props.blur_x > 0.0).then_some(Length::Px(props.blur_x)),
1126 radius_y: (props.blur_y > 0.0).then_some(Length::Px(props.blur_y)),
1127 });
1128}
1129
1130#[allow(clippy::too_many_arguments)]
1131fn apply_motion_blur_smear(
1132 css: &mut CssStyle,
1133 effects: &[rustmotion_core::schema::AnimationEffect],
1134 component: &Component,
1135 extra_delay: f64,
1136 t: f64,
1137 scene_duration: f64,
1138 fps: u32,
1139) {
1140 use rustmotion_core::css::style::FilterFn;
1141 use rustmotion_core::css::units::Length;
1142 use rustmotion_core::schema::AnimationEffect;
1143 use rustmotion_core::schema::MotionBlurMode;
1144
1145 let Some(cfg) = effects.iter().find_map(|e| match e {
1146 AnimationEffect::MotionBlur(c) if c.mode == MotionBlurMode::Smear => Some(c),
1147 _ => None,
1148 }) else {
1149 return;
1150 };
1151
1152 let shutter_window = (cfg.shutter / fps.max(1) as f64).max(1e-6);
1153 let sample = |at: f64| -> (f32, f32) {
1154 match effective_effects(component, extra_delay, at) {
1155 Some(e) => {
1156 let p = resolve_props_for_effects(&e, at, scene_duration);
1157 (p.translate_x, p.translate_y)
1158 }
1159 None => (0.0, 0.0),
1160 }
1161 };
1162 let (x0, y0) = sample(t - shutter_window);
1163 let (x1, y1) = sample(t);
1164 let dx = x1 - x0;
1165 let dy = y1 - y0;
1166 let magnitude = dx.hypot(dy);
1167 if magnitude < 0.5 {
1168 return;
1169 }
1170 let angle = dy.atan2(dx).to_degrees();
1171 css.filter
1172 .get_or_insert_with(Vec::new)
1173 .push(FilterFn::DirectionalBlur {
1174 angle,
1175 radius: Length::Px(magnitude),
1176 });
1177}
1178
1179fn component_intrinsic(
1180 component: &Component,
1181 cascaded_css: &CssStyle,
1182) -> Option<Arc<dyn rustmotion_core::engine::box_tree::IntrinsicMeasure>> {
1183 let cascaded_component = component.with_cascaded_style(cascaded_css);
1184 let component = cascaded_component.as_ref().unwrap_or(component);
1185
1186 use Component::*;
1187 match component {
1188 Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))),
1189 GradientText(t) => Some(Arc::new(
1190 crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t),
1191 )),
1192 Caption(c) => Some(Arc::new(crate::intrinsic::CaptionIntrinsic::from_caption(
1193 c,
1194 ))),
1195 Kbd(k) => Some(Arc::new(crate::intrinsic::KbdIntrinsic::from_kbd(k))),
1196 Counter(c) => Some(Arc::new(crate::intrinsic::CounterIntrinsic::from_counter(
1197 c,
1198 ))),
1199 NumberWheel(w) => Some(Arc::new(
1200 crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w),
1201 )),
1202 Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))),
1203 Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))),
1204 RichText(rt) => Some(Arc::new(
1205 crate::intrinsic::RichTextIntrinsic::from_rich_text(rt),
1206 )),
1207 _ => None,
1208 }
1209}
1210
1211#[allow(clippy::too_many_arguments)]
1212fn container_children<'a>(
1213 component: &'a Component,
1214 components: &mut Vec<Option<&'a ChildComponent>>,
1215 stagger_delays: &mut Vec<f64>,
1216 time_params: &mut Vec<(f64, f64)>,
1217 ghost_principal: &mut Vec<(NodeId, NodeId)>,
1218 next_id: &mut NodeId,
1219 anim: Option<BuildAnimationCtx>,
1220 parent_path: &str,
1221 inherited_delay: f64,
1222 time_remap: (f64, f64),
1223 parent_css: &CssStyle,
1224 viewport: (f32, f32),
1225 outer_scope: Option<&dyn Scope>,
1226 warn_unresolved: bool,
1227) -> Vec<BoxNode> {
1228 let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option<f32>, f64, f64) =
1229 match component {
1230 Component::Container(c) => (
1231 &c.children,
1232 c.stagger,
1233 c.time_scale.unwrap_or(1.0),
1234 c.time_offset.unwrap_or(0.0),
1235 ),
1236 _ => return Vec::new(),
1237 };
1238
1239 let child_scale = child_scale.max(1e-6);
1240
1241 let (scale_acc, shift_acc) = time_remap;
1242 let new_scale = child_scale * scale_acc;
1243 let new_shift = child_scale * (shift_acc - child_offset);
1244 let child_remap = (new_scale, new_shift);
1245
1246 let step = stagger.unwrap_or(0.0) as f64;
1247 let mut result = Vec::new();
1248 for (j, c) in children.iter().enumerate() {
1249 result.extend(build_child(
1250 c,
1251 components,
1252 stagger_delays,
1253 time_params,
1254 ghost_principal,
1255 next_id,
1256 anim,
1257 format!("{parent_path}/children/{j}"),
1258 inherited_delay + j as f64 * step,
1259 child_remap,
1260 parent_css,
1261 viewport,
1262 outer_scope,
1263 warn_unresolved,
1264 ));
1265 }
1266 result
1267}
1268
1269fn component_css(component: &Component) -> CssStyle {
1270 let mut css = component_style(component).clone();
1271 apply_default_display(component, &mut css);
1272 apply_intrinsic_overrides(component, &mut css);
1273 css
1274}
1275
1276fn apply_default_display(component: &Component, css: &mut CssStyle) {
1277 use rustmotion_core::css::style::Display;
1278 if css.display.is_some() {
1279 return;
1280 }
1281 if matches!(component, Component::Container(_)) {
1282 css.display = Some(if css.grid_template_columns.is_some() {
1283 Display::Grid
1284 } else {
1285 Display::Flex
1286 });
1287 }
1288}
1289
1290fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) -> f32 {
1291 use rustmotion_core::engine::renderer::{
1292 emoji_typeface, measure_text_with_fallback, subpixel_font, typeface_with_fallback,
1293 };
1294 let style = if bold {
1295 skia_safe::FontStyle::bold()
1296 } else {
1297 skia_safe::FontStyle::normal()
1298 };
1299 let Ok(typeface) = typeface_with_fallback(family, style) else {
1300 return 0.0;
1301 };
1302 let font = subpixel_font(typeface, font_size);
1303 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
1304 measure_text_with_fallback(text, &font, &emoji_font, 0.0)
1305}
1306
1307fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
1308 use Component::*;
1309 match component {
1310 Text(t) => {
1311 let nowrap = matches!(
1312 t.style.white_space,
1313 Some(
1314 rustmotion_core::css::style::WhiteSpace::Nowrap
1315 | rustmotion_core::css::style::WhiteSpace::Pre
1316 )
1317 );
1318 if nowrap && css.min_width.is_none() {
1319 css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1320 }
1321 }
1322 GradientText(t) => {
1323 let nowrap = matches!(
1324 t.style.white_space,
1325 Some(
1326 rustmotion_core::css::style::WhiteSpace::Nowrap
1327 | rustmotion_core::css::style::WhiteSpace::Pre
1328 )
1329 );
1330 if nowrap && css.min_width.is_none() {
1331 css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1332 }
1333 }
1334 Caption(c) => {
1335 let nowrap = matches!(
1336 c.style.white_space,
1337 Some(
1338 rustmotion_core::css::style::WhiteSpace::Nowrap
1339 | rustmotion_core::css::style::WhiteSpace::Pre
1340 )
1341 );
1342 if nowrap && css.min_width.is_none() {
1343 css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1344 }
1345 }
1346 Divider(d) => match d.direction {
1347 DividerDirection::Horizontal => {
1348 if css.height.is_none() {
1349 css.height = Some(CSize::Length(CLP::Px(d.thickness)));
1350 }
1351 if css.width.is_none() {
1352 css.width = match d.length {
1353 Some(l) => Some(CSize::Length(CLP::Px(l))),
1354 None => Some(CSize::Length(CLP::String("100%".into()))),
1355 };
1356 }
1357 if css.align_self.is_none() {
1358 css.align_self = Some(AlignSelf::Stretch);
1359 }
1360 }
1361 DividerDirection::Vertical => {
1362 if css.width.is_none() {
1363 css.width = Some(CSize::Length(CLP::Px(d.thickness)));
1364 }
1365 if css.height.is_none() {
1366 css.height = match d.length {
1367 Some(l) => Some(CSize::Length(CLP::Px(l))),
1368 None => Some(CSize::Length(CLP::String("100%".into()))),
1369 };
1370 }
1371 }
1372 },
1373 Line(l) => {
1374 let w = (l.x2 - l.x1).abs().max(1.0);
1375 let h = (l.y2 - l.y1).abs().max(1.0);
1376 if css.width.is_none() {
1377 css.width = Some(CSize::Length(CLP::Px(w)));
1378 }
1379 if css.height.is_none() {
1380 css.height = Some(CSize::Length(CLP::Px(h)));
1381 }
1382 }
1383 Arrow(a) => {
1384 let pad = ARROW_BBOX_PADDING + a.arrow_size.max(0.0);
1385 let w = (a.x2 - a.x1).abs().max(1.0) + pad;
1386 let h = (a.y2 - a.y1).abs().max(1.0) + pad;
1387 if css.width.is_none() {
1388 css.width = Some(CSize::Length(CLP::Px(w)));
1389 }
1390 if css.height.is_none() {
1391 css.height = Some(CSize::Length(CLP::Px(h)));
1392 }
1393 }
1394 Connector(c) => {
1395 let pad = ARROW_BBOX_PADDING + c.arrow_size.max(0.0);
1396 let w = (c.to.x - c.from.x).abs().max(1.0) + pad;
1397 let h = (c.to.y - c.from.y).abs().max(1.0) + pad;
1398 if css.width.is_none() {
1399 css.width = Some(CSize::Length(CLP::Px(w)));
1400 }
1401 if css.height.is_none() {
1402 css.height = Some(CSize::Length(CLP::Px(h)));
1403 }
1404 }
1405 Cursor(cur) => {
1406 if css.width.is_none() {
1407 css.width = Some(CSize::Length(CLP::Px(cur.width)));
1408 }
1409 if css.height.is_none() {
1410 css.height = Some(CSize::Length(CLP::Px(cur.height)));
1411 }
1412 }
1413 SuccessCheck(c) => {
1414 apply_default_size(css, c.size, c.size);
1415 }
1416 Pointer(p) => {
1417 if css.width.is_none() {
1418 css.width = Some(CSize::Length(CLP::Px(p.size * 0.6)));
1419 }
1420 if css.height.is_none() {
1421 css.height = Some(CSize::Length(CLP::Px(p.size)));
1422 }
1423 }
1424 Particle(_) => {
1425 if css.width.is_none() {
1426 css.width = Some(CSize::Length(CLP::String("100%".into())));
1427 }
1428 if css.height.is_none() {
1429 css.height = Some(CSize::Length(CLP::String("100%".into())));
1430 }
1431 }
1432 Emitter(_) => {
1433 if css.width.is_none() {
1434 css.width = Some(CSize::Length(CLP::String("100%".into())));
1435 }
1436 if css.height.is_none() {
1437 css.height = Some(CSize::Length(CLP::String("100%".into())));
1438 }
1439 }
1440 Switch(c) => {
1441 if css.width.is_none() {
1442 css.width = Some(CSize::Length(CLP::Px(c.width)));
1443 }
1444 if css.height.is_none() {
1445 css.height = Some(CSize::Length(CLP::Px(c.height)));
1446 }
1447 }
1448 Slider(c) => {
1449 if css.width.is_none() {
1450 css.width = Some(CSize::Length(CLP::Px(c.width)));
1451 }
1452 if css.height.is_none() {
1453 css.height = Some(CSize::Length(CLP::Px(c.height)));
1454 }
1455 }
1456 Progress(c) => {
1457 if css.width.is_none() {
1458 css.width = Some(CSize::Length(CLP::Px(c.width)));
1459 }
1460 if css.height.is_none() {
1461 css.height = Some(CSize::Length(CLP::Px(c.height)));
1462 }
1463 }
1464 List(c) => {
1465 if css.width.is_none() {
1466 css.width = Some(CSize::Length(CLP::Px(c.width)));
1467 }
1468 if css.height.is_none() {
1469 let font_size = c
1470 .style
1471 .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0);
1472 let line_height = font_size * 1.3;
1473 let n = c.items.len() as f32;
1474 let h = n * line_height + (n - 1.0).max(0.0) * c.gap;
1475 css.height = Some(CSize::Length(CLP::Px(h)));
1476 }
1477 }
1478 Timeline(c) => {
1479 if css.width.is_none() {
1480 css.width = Some(CSize::Length(CLP::Px(c.width)));
1481 }
1482 if css.height.is_none() {
1483 let r = c.node_radius;
1484 let h = match c.direction {
1485 TimelineDirection::Horizontal => r * 2.0 + c.font_size * 2.5 + 24.0,
1486 TimelineDirection::Vertical => {
1487 let n = c.steps.len().max(1) as f32;
1488 n * (r * 2.0 + 64.0)
1489 }
1490 };
1491 css.height = Some(CSize::Length(CLP::Px(h)));
1492 }
1493 }
1494 Rating(c) => {
1495 if css.width.is_none() {
1496 let count = c.max as f32;
1497 let w = count * c.size + (count - 1.0).max(0.0) * c.gap;
1498 css.width = Some(CSize::Length(CLP::Px(w)));
1499 }
1500 if css.height.is_none() {
1501 css.height = Some(CSize::Length(CLP::Px(c.size)));
1502 }
1503 }
1504 Avatar(c) => {
1505 if css.width.is_none() {
1506 css.width = Some(CSize::Length(CLP::Px(c.size)));
1507 }
1508 if css.height.is_none() {
1509 css.height = Some(CSize::Length(CLP::Px(c.size)));
1510 }
1511 }
1512 AvatarGroup(c) => {
1513 if css.width.is_none() {
1514 let visible = c.visible_count() as f32;
1515 let extra = if c.overflow_count() > 0 { 1.0 } else { 0.0 };
1516 let total = visible + extra;
1517 let step = (c.size - c.overlap).max(0.0);
1518 let w = if total <= 0.0 {
1519 0.0
1520 } else {
1521 c.size + (total - 1.0) * step
1522 };
1523 css.width = Some(CSize::Length(CLP::Px(w)));
1524 }
1525 if css.height.is_none() {
1526 css.height = Some(CSize::Length(CLP::Px(c.size)));
1527 }
1528 }
1529 QrCode(c) => {
1530 if css.width.is_none() {
1531 css.width = Some(CSize::Length(CLP::Px(c.size)));
1532 }
1533 if css.height.is_none() {
1534 css.height = Some(CSize::Length(CLP::Px(c.size)));
1535 }
1536 }
1537 Countdown(c) if (css.width.is_none() || css.height.is_none()) => {
1538 let visible = [c.show_hours, c.show_minutes, c.show_seconds]
1539 .iter()
1540 .filter(|v| **v)
1541 .count() as f32;
1542 let box_w = c.digit_size * 0.75;
1543 let box_h = c.digit_size * 1.2;
1544 let w = (visible * 2.0 * box_w) + ((visible - 1.0).max(0.0) * c.gap);
1545 if css.width.is_none() {
1546 css.width = Some(CSize::Length(CLP::Px(w)));
1547 }
1548 if css.height.is_none() {
1549 css.height = Some(CSize::Length(CLP::Px(box_h)));
1550 }
1551 }
1552 AudioSpectrum(_) => {
1553 if css.width.is_none() {
1554 css.width = Some(CSize::Length(CLP::Px(400.0)));
1555 }
1556 if css.height.is_none() {
1557 css.height = Some(CSize::Length(CLP::Px(120.0)));
1558 }
1559 }
1560 Waveform(_) => {
1561 if css.width.is_none() {
1562 css.width = Some(CSize::Length(CLP::Px(400.0)));
1563 }
1564 if css.height.is_none() {
1565 css.height = Some(CSize::Length(CLP::Px(80.0)));
1566 }
1567 }
1568
1569 Callout(t) => {
1570 let font_size = t
1571 .style
1572 .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0);
1573 let family = t.style.font_family_or("Inter");
1574 let text_w = measure_text_line_width(&t.text, font_size, family, false);
1575 let h_pad = 12.0;
1576 let v_pad = 16.0;
1577 let line_h = font_size * 1.4;
1578 let (extra_w, extra_h) = match t.arrow_direction {
1579 CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0),
1580 CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size),
1581 };
1582 apply_default_size(
1583 css,
1584 text_w + h_pad * 2.0 + extra_w,
1585 line_h + v_pad + extra_h,
1586 );
1587 }
1588 Tooltip(t) => {
1589 let font_size = t.style.font_size_px_ctx(
1590 &crate::intrinsic::measure_time_font_size_ctx(0.0),
1591 t.font_size,
1592 );
1593 let family = t.style.font_family_or("Inter");
1594 let text_w = measure_text_line_width(&t.text, font_size, family, false);
1595 let h_pad = 12.0;
1596 let v_pad = 16.0;
1597 let line_h = font_size * 1.4;
1598 let (extra_w, extra_h) = match t.arrow {
1599 TooltipArrow::Left | TooltipArrow::Right => (t.arrow_size, 0.0),
1600 TooltipArrow::Top | TooltipArrow::Bottom | TooltipArrow::None => {
1601 (0.0, t.arrow_size)
1602 }
1603 };
1604 apply_default_size(
1605 css,
1606 text_w + h_pad * 2.0 + extra_w,
1607 line_h + v_pad + extra_h,
1608 );
1609 }
1610 PillNav(p) => {
1611 let font_size = p
1612 .style
1613 .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 14.0);
1614 let family = p.style.font_family_or("Inter");
1615 let h_pad = font_size * 1.2;
1616 let n = p.items.len() as f32;
1617 let labels_w: f32 = p
1618 .items
1619 .iter()
1620 .map(|label| measure_text_line_width(label, font_size, family, false) + h_pad * 2.0)
1621 .sum();
1622 let total_w = labels_w + p.gap * (n + 1.0).max(1.0);
1623 apply_default_size(css, total_w, p.height);
1624 }
1625 Marquee(m) => {
1626 let font_size = m.style.font_size_px_ctx(
1627 &crate::intrinsic::measure_time_font_size_ctx(0.0),
1628 m.font_size,
1629 );
1630 apply_default_size(css, 800.0, font_size * 2.0);
1631 }
1632 Stepper(s) => {
1633 let n = (s.steps.len().max(1)) as f32;
1634 let has_desc = s.steps.iter().any(|st| st.description.is_some());
1635 const LABEL_FS: f32 = 14.0;
1636 const DESC_FS: f32 = 11.0;
1637 let max_label_w = s
1638 .steps
1639 .iter()
1640 .map(|st| measure_text_line_width(&st.label, LABEL_FS, "Inter", false))
1641 .fold(0.0_f32, f32::max);
1642 let max_desc_w = s
1643 .steps
1644 .iter()
1645 .filter_map(|st| st.description.as_deref())
1646 .map(|d| measure_text_line_width(d, DESC_FS, "Inter", false))
1647 .fold(0.0_f32, f32::max);
1648 match s.orientation {
1649 StepperOrientation::Horizontal => {
1650 let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0);
1651 let label_h = LABEL_FS * 1.3;
1652 let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 };
1653 let h = s.node_size + 4.0 + 12.0 + label_h + desc_h;
1654 apply_default_size(css, per_step * n, h);
1655 }
1656 StepperOrientation::Vertical => {
1657 let label_w = max_label_w.max(max_desc_w);
1658 let w = s.node_size + 12.0 + label_w + 24.0;
1659 let label_block = if has_desc {
1660 LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0
1661 } else {
1662 LABEL_FS * 1.3 + 8.0
1663 };
1664 let per_step = (s.node_size * 2.0).max(label_block);
1665 apply_default_size(css, w, per_step * n);
1666 }
1667 }
1668 }
1669 TagCloud(tc) => {
1670 let n = tc.tags.len();
1671 if n > 0 {
1672 let min_w = tc.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min);
1673 let max_w = tc.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max);
1674 let range = (max_w - min_w).max(0.001);
1675 const H_GAP: f32 = 12.0;
1676 const V_GAP: f32 = 8.0;
1677 let total_w: f32 = tc
1678 .tags
1679 .iter()
1680 .map(|t| {
1681 let normalized = ((t.weight - min_w) / range) as f32;
1682 let fs =
1683 tc.min_font_size + normalized * (tc.max_font_size - tc.min_font_size);
1684 measure_text_line_width(&t.text, fs, "Inter", true) + H_GAP
1685 })
1686 .sum();
1687 const CAP_W: f32 = 600.0;
1688 let box_w = total_w.clamp(CAP_W * 0.3, CAP_W);
1689 let lines = (total_w / box_w).ceil().max(1.0);
1690 let line_h = tc.max_font_size * 1.3;
1691 let box_h = lines * line_h + (lines - 1.0).max(0.0) * V_GAP;
1692 apply_default_size(css, box_w, box_h);
1693 }
1694 }
1695 Heatmap(h) => {
1696 let rows = h.data.len();
1697 let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0);
1698 let step = h.cell_size + h.cell_gap;
1699 let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1700 let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1701 apply_default_size(css, w, hh);
1702 }
1703 Sparkline(_) => {
1704 apply_default_size(css, 120.0, 40.0);
1705 }
1706 Stat(_) => {
1707 apply_default_size(css, 280.0, 180.0);
1708 }
1709 Gauge(g) => {
1710 const TARGET_RADIUS: f32 = 88.0;
1711 let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0);
1712 apply_default_size(css, size, size);
1713 }
1714 DotMap(_) => {
1715 apply_default_size(css, 640.0, 320.0);
1716 }
1717 Comparison(_) => {
1718 apply_default_size(css, 520.0, 280.0);
1719 }
1720 Treemap(_) => {
1721 apply_default_size(css, 416.0, 368.0);
1722 }
1723 Chart(c) => {
1724 let round = matches!(
1725 c.chart_type,
1726 ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar
1727 );
1728 let (dw, dh) = if round {
1729 (320.0, 320.0)
1730 } else {
1731 (400.0, 300.0)
1732 };
1733 apply_default_size(css, dw, dh);
1734 }
1735 Skeleton(s) => match s.variant {
1736 SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0),
1737 SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0),
1738 SkeletonVariant::Text => {
1739 let n = s.lines.max(1) as f32;
1740 let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap;
1741 apply_default_size(css, 240.0, h);
1742 }
1743 },
1744 Mockup(m) => {
1745 let (dw, dh) = match m.device {
1746 MockupDevice::Iphone | MockupDevice::Android => (320.0, 690.0),
1747 MockupDevice::Laptop => (640.0, 400.0),
1748 MockupDevice::Browser => (640.0, 360.0),
1749 };
1750 apply_default_size(css, dw, dh);
1751 }
1752 Icon(_) => {
1753 apply_default_size(css, 64.0, 64.0);
1754 }
1755 Svg(_) => {
1756 apply_default_size(css, 200.0, 200.0);
1757 }
1758 Shape(_) => {
1759 apply_default_size(css, 80.0, 80.0);
1760 }
1761 Image(_) => {
1762 apply_default_size(css, 400.0, 300.0);
1763 }
1764 Video(_) | Gif(_) => {
1765 apply_default_size(css, 400.0, 225.0);
1766 }
1767 Lottie(_) => {
1768 apply_default_size(css, 300.0, 300.0);
1769 }
1770
1771 _ => {}
1772 }
1773}
1774
1775fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) {
1776 let ratio = css.aspect_ratio.filter(|r| *r > 0.0);
1777 match (css.width.is_some(), css.height.is_some()) {
1778 (true, true) => {}
1779 (true, false) => {
1780 let h = fixed_px(css.width.as_ref())
1781 .zip(ratio)
1782 .map(|(w, r)| w / r)
1783 .unwrap_or(dh);
1784 css.height = Some(CSize::Length(CLP::Px(h)));
1785 }
1786 (false, true) => {
1787 let w = fixed_px(css.height.as_ref())
1788 .zip(ratio)
1789 .map(|(h, r)| h * r)
1790 .unwrap_or(dw);
1791 css.width = Some(CSize::Length(CLP::Px(w)));
1792 }
1793 (false, false) => {
1794 css.width = Some(CSize::Length(CLP::Px(dw)));
1795 let h = ratio.map(|r| dw / r).unwrap_or(dh);
1796 css.height = Some(CSize::Length(CLP::Px(h)));
1797 }
1798 }
1799}
1800
1801fn fixed_px(size: Option<&CSize>) -> Option<f32> {
1802 match size? {
1803 CSize::Length(lp) => match lp.try_parse()? {
1804 rustmotion_core::css::units::ParsedLength::Px(v) => Some(v),
1805 _ => None,
1806 },
1807 _ => None,
1808 }
1809}
1810
1811fn component_style(c: &Component) -> &CssStyle {
1812 use Component::*;
1813 match c {
1814 Text(c) => &c.style,
1815 Shape(c) => &c.style,
1816 Image(c) => &c.style,
1817 Icon(c) => &c.style,
1818 Svg(c) => &c.style,
1819 Video(c) => &c.style,
1820 Gif(c) => &c.style,
1821 Counter(c) => &c.style,
1822 Cursor(c) => &c.style,
1823 Caption(c) => &c.style,
1824 Connector(c) => &c.style,
1825 Avatar(c) => &c.style,
1826 AvatarGroup(c) => &c.style,
1827 Arrow(c) => &c.style,
1828 Badge(c) => &c.style,
1829 Callout(c) => &c.style,
1830 Chart(c) => &c.style,
1831 Comparison(c) => &c.style,
1832 Countdown(c) => &c.style,
1833 Divider(c) => &c.style,
1834 DotMap(c) => &c.style,
1835 Emitter(c) => &c.style,
1836 Gauge(c) => &c.style,
1837 GradientText(c) => &c.style,
1838 Heatmap(c) => &c.style,
1839 Kbd(c) => &c.style,
1840 Line(c) => &c.style,
1841 List(c) => &c.style,
1842 Lottie(c) => &c.style,
1843 Marquee(c) => &c.style,
1844 Mockup(c) => &c.style,
1845 Particle(c) => &c.style,
1846 PillNav(c) => &c.style,
1847 Progress(c) => &c.style,
1848 QrCode(c) => &c.style,
1849 NumberWheel(c) => &c.style,
1850 SuccessCheck(c) => &c.style,
1851 Pointer(c) => &c.style,
1852 Rating(c) => &c.style,
1853 Skeleton(c) => &c.style,
1854 Slider(c) => &c.style,
1855 Sparkline(c) => &c.style,
1856 Stat(c) => &c.style,
1857 Stepper(c) => &c.style,
1858 Switch(c) => &c.style,
1859 RichText(c) => &c.style,
1860 Table(c) => &c.style,
1861 TagCloud(c) => &c.style,
1862 Timeline(c) => &c.style,
1863 Tooltip(c) => &c.style,
1864 Treemap(c) => &c.style,
1865 Container(c) => &c.style,
1866 AudioSpectrum(c) => &c.style,
1867 Waveform(c) => &c.style,
1868 }
1869}
1870
1871pub fn component_kind(c: &Component) -> &'static str {
1872 use Component::*;
1873 match c {
1874 Text(_) => "text",
1875 Shape(_) => "shape",
1876 Image(_) => "image",
1877 Icon(_) => "icon",
1878 Svg(_) => "svg",
1879 Video(_) => "video",
1880 Gif(_) => "gif",
1881 Counter(_) => "counter",
1882 Cursor(_) => "cursor",
1883 Caption(_) => "caption",
1884 Connector(_) => "connector",
1885 Avatar(_) => "avatar",
1886 AvatarGroup(_) => "avatar_group",
1887 Arrow(_) => "arrow",
1888 Badge(_) => "badge",
1889 Callout(_) => "callout",
1890 Chart(_) => "chart",
1891 Comparison(_) => "comparison",
1892 Countdown(_) => "countdown",
1893 Divider(_) => "divider",
1894 DotMap(_) => "dot_map",
1895 Emitter(_) => "emitter",
1896 Gauge(_) => "gauge",
1897 GradientText(_) => "gradient_text",
1898 Heatmap(_) => "heatmap",
1899 Kbd(_) => "kbd",
1900 Line(_) => "line",
1901 List(_) => "list",
1902 Lottie(_) => "lottie",
1903 Marquee(_) => "marquee",
1904 Mockup(_) => "mockup",
1905 Particle(_) => "particle",
1906 PillNav(_) => "pill_nav",
1907 Progress(_) => "progress",
1908 QrCode(_) => "qr_code",
1909 NumberWheel(_) => "number_wheel",
1910 SuccessCheck(_) => "success_check",
1911 Pointer(_) => "pointer",
1912 Rating(_) => "rating",
1913 Skeleton(_) => "skeleton",
1914 Slider(_) => "slider",
1915 Sparkline(_) => "sparkline",
1916 Stat(_) => "stat",
1917 Stepper(_) => "stepper",
1918 Switch(_) => "switch",
1919 RichText(_) => "rich_text",
1920 Table(_) => "table",
1921 TagCloud(_) => "tag_cloud",
1922 Timeline(_) => "timeline",
1923 Tooltip(_) => "tooltip",
1924 Treemap(_) => "treemap",
1925 Container(_) => "div",
1926 AudioSpectrum(_) => "audio_spectrum",
1927 Waveform(_) => "waveform",
1928 }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933 use super::*;
1934
1935 #[test]
1936 fn a_timeline_step_leaves_the_value_alone_until_its_at() {
1937 let component: Component = serde_json::from_value(json!({
1938 "type": "shape",
1939 "shape": "circle",
1940 "fill": "#1EA2C2",
1941 "style": { "width": 120, "height": 120 },
1942 "timeline": [
1943 { "at": 3.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
1944 { "property": "translate_x", "easing": "linear", "keyframes": [
1945 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 200.0 }] }] }] },
1946 { "at": 6.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
1947 { "property": "translate_x", "easing": "linear", "keyframes": [
1948 { "time": 0.0, "value": 200.0 }, { "time": 1.0, "value": 0.0 }] }] }] }
1949 ]
1950 }))
1951 .expect("component deserializes");
1952
1953 let tx = |t: f64| match effective_effects(&component, 0.0, t) {
1954 Some(effects) => resolve_props_for_effects(&effects, t, 9.0).translate_x as f64,
1955 None => AnimatedProperties::default().translate_x as f64,
1956 };
1957
1958 assert!(
1959 tx(0.5).abs() < 1.0,
1960 "before either step, translate_x is 0, got {}",
1961 tx(0.5)
1962 );
1963 assert!(
1964 (tx(3.5) - 100.0).abs() < 2.0,
1965 "halfway through step one, got {}",
1966 tx(3.5)
1967 );
1968 assert!(
1969 (tx(5.0) - 200.0).abs() < 1.0,
1970 "step one has ended and holds, got {}",
1971 tx(5.0)
1972 );
1973 assert!(
1974 (tx(6.5) - 100.0).abs() < 2.0,
1975 "halfway through step two, got {}",
1976 tx(6.5)
1977 );
1978 assert!(
1979 tx(8.0).abs() < 1.0,
1980 "step two has ended and holds, got {}",
1981 tx(8.0)
1982 );
1983 }
1984
1985 #[test]
1986 fn start_at_rebases_the_entrance_animation_clock() {
1987 let scene = vec![ChildComponent {
1988 id: None,
1989 component: serde_json::from_value(json!({
1990 "type": "shape",
1991 "shape": "rect",
1992 "fill": "#1EA2C2",
1993 "start_at": 2.0,
1994 "style": {
1995 "width": 120, "height": 120,
1996 "animation": [{ "name": "fade_in_down", "duration": 0.6 }]
1997 }
1998 }))
1999 .expect("component deserializes"),
2000 position: None,
2001 x: None,
2002 y: None,
2003 z_index: None,
2004 bleed: false,
2005 }];
2006
2007 let opacity_at = |t: f64| -> f32 {
2008 let built = build_scene_at_time(
2009 &scene,
2010 (400.0, 400.0),
2011 default_root_css((400.0, 400.0)),
2012 BuildAnimationCtx {
2013 time: t,
2014 scenario_time: t,
2015 scene_duration: 6.0,
2016 fps: 30,
2017 },
2018 );
2019 built.root.children[0].css.opacity.unwrap_or(1.0)
2020 };
2021
2022 assert!(
2023 opacity_at(2.0) < 0.3,
2024 "at start_at (2.0) the fade_in_down entrance should just be beginning, got opacity {}",
2025 opacity_at(2.0)
2026 );
2027 assert!(
2028 opacity_at(2.6) > 0.9,
2029 "0.6s after start_at (the entrance's own duration) it should have finished, got opacity {}",
2030 opacity_at(2.6)
2031 );
2032 }
2033
2034 #[test]
2035 fn start_at_rebases_an_exit_animation_declared_after_the_entrance() {
2036 let scene = vec![ChildComponent {
2037 id: None,
2038 component: serde_json::from_value(json!({
2039 "type": "shape",
2040 "shape": "rect",
2041 "fill": "#1EA2C2",
2042 "start_at": 2.0,
2043 "style": {
2044 "width": 120, "height": 120,
2045 "animation": [
2046 { "name": "fade_in_down", "duration": 0.6 },
2047 { "name": "fade_out_up", "delay": 0.85, "duration": 0.3 }
2048 ]
2049 }
2050 }))
2051 .expect("component deserializes"),
2052 position: None,
2053 x: None,
2054 y: None,
2055 z_index: None,
2056 bleed: false,
2057 }];
2058
2059 let opacity_at = |t: f64| -> f32 {
2060 let built = build_scene_at_time(
2061 &scene,
2062 (400.0, 400.0),
2063 default_root_css((400.0, 400.0)),
2064 BuildAnimationCtx {
2065 time: t,
2066 scenario_time: t,
2067 scene_duration: 6.0,
2068 fps: 30,
2069 },
2070 );
2071 built.root.children[0].css.opacity.unwrap_or(1.0)
2072 };
2073
2074 assert!(
2075 opacity_at(2.5) > 0.5,
2076 "the exit's own delay (0.85s) has not elapsed since start_at (2.0), the node should \
2077 still be visible, got opacity {}",
2078 opacity_at(2.5)
2079 );
2080 }
2081
2082 use rustmotion_core::css::style::{
2083 CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize,
2084 };
2085 use rustmotion_core::css::taffy_bridge::ConversionContext;
2086 use rustmotion_core::css::units::LengthPercentage;
2087 use rustmotion_core::css::units::LengthPercentage as CLP;
2088 use rustmotion_core::engine::layout_pass::run_layout;
2089 use serde_json::json;
2090
2091 fn make_card(children: Vec<ChildComponent>, style: CssStyle) -> Component {
2092 Component::Container(crate::container::ContainerComponent {
2093 children,
2094 timing: Default::default(),
2095 style,
2096 timeline: Vec::new(),
2097 stagger: None,
2098 time_scale: None,
2099 time_offset: None,
2100 })
2101 }
2102
2103 fn make_shape(width: f32, height: f32) -> ChildComponent {
2104 ChildComponent {
2105 id: None,
2106 component: Component::Shape(crate::shape::Shape {
2107 shape: rustmotion_core::schema::ShapeType::Rect,
2108 text: None,
2109 timing: Default::default(),
2110 style: CssStyle {
2111 width: Some(CSize::Length(CLP::Px(width))),
2112 height: Some(CSize::Length(CLP::Px(height))),
2113 ..Default::default()
2114 },
2115 timeline: Vec::new(),
2116 stagger: None,
2117 fill: None,
2118 stroke: None,
2119 draw_start: None,
2120 path_morph: None,
2121 }),
2122 position: None,
2123 x: None,
2124 y: None,
2125 z_index: None,
2126 bleed: false,
2127 }
2128 }
2129
2130 fn make_text(content: &str, style: CssStyle) -> ChildComponent {
2131 ChildComponent {
2132 id: None,
2133 component: Component::Text(crate::text::Text {
2134 content: content.to_string(),
2135 max_width: None,
2136 timing: Default::default(),
2137 style,
2138 timeline: Vec::new(),
2139 stagger: None,
2140 text_shadow: None,
2141 stroke: None,
2142 text_background: None,
2143 caret: None,
2144 states: Vec::new(),
2145 swap: None,
2146 morph: None,
2147 }),
2148 position: None,
2149 x: None,
2150 y: None,
2151 z_index: None,
2152 bleed: false,
2153 }
2154 }
2155
2156 #[test]
2157 fn empty_scene_has_only_root() {
2158 let built = build_scene(&[], (1920.0, 1080.0));
2159 assert_eq!(built.root.children.len(), 0);
2160 assert_eq!(built.components.len(), 1);
2161 }
2162
2163 #[test]
2164 fn component_kind_labels() {
2165 assert_eq!(component_kind(&make_shape(100.0, 50.0).component), "shape");
2166 }
2167
2168 #[test]
2169 fn build_child_records_source_path() {
2170 let card = make_card(
2171 vec![make_shape(10.0, 10.0), make_shape(10.0, 10.0)],
2172 CssStyle::default(),
2173 );
2174 let scene = vec![ChildComponent {
2175 id: None,
2176 component: card,
2177 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2178 x: None,
2179 y: None,
2180 z_index: None,
2181 bleed: false,
2182 }];
2183 let built = build_scene(&scene, (800.0, 600.0));
2184 let card_box = &built.root.children[0];
2185 assert_eq!(card_box.source_path.as_deref(), Some("/children/0"));
2186 assert_eq!(
2187 card_box.children[1].source_path.as_deref(),
2188 Some("/children/0/children/1")
2189 );
2190 }
2191
2192 #[test]
2193 fn flex_card_with_two_shapes_lays_out_vertically() {
2194 let card = make_card(
2195 vec![make_shape(200.0, 50.0), make_shape(200.0, 50.0)],
2196 CssStyle {
2197 display: Some(Display::Flex),
2198 flex_direction: Some(FlexDirection::Column),
2199 gap: Some(Gap::Uniform(LengthPercentage::Px(10.0))),
2200 padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2201 width: Some(CSize::Length(CLP::Px(300.0))),
2202 height: Some(CSize::Length(CLP::Px(200.0))),
2203 ..Default::default()
2204 },
2205 );
2206 let scene = vec![ChildComponent {
2207 id: None,
2208 component: card,
2209 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2210 x: None,
2211 y: None,
2212 z_index: None,
2213 bleed: false,
2214 }];
2215 let built = build_scene(&scene, (1920.0, 1080.0));
2216 assert_eq!(built.root.children.len(), 1);
2217 let card_box = &built.root.children[0];
2218 assert_eq!(card_box.css.display, Some(Display::Flex));
2219 assert_eq!(card_box.css.flex_direction, Some(FlexDirection::Column));
2220 assert_eq!(card_box.children.len(), 2);
2221
2222 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2223 let card_layout = layout.get(card_box.id).expect("card laid out");
2224 assert_eq!(card_layout.x, 0.0);
2225 assert_eq!(card_layout.y, 0.0);
2226 assert_eq!(card_layout.width, 300.0);
2227 assert_eq!(card_layout.height, 200.0);
2228
2229 let c1 = layout
2230 .get(card_box.children[0].id)
2231 .expect("shape 1 laid out");
2232 let c2 = layout
2233 .get(card_box.children[1].id)
2234 .expect("shape 2 laid out");
2235 assert_eq!(c1.x, 20.0);
2236 assert_eq!(c1.y, 20.0);
2237 assert_eq!(c2.x, 20.0);
2238 assert_eq!(c2.y, 80.0);
2239 }
2240
2241 #[test]
2242 fn absolute_child_uses_top_left() {
2243 let scene = vec![ChildComponent {
2244 id: None,
2245 component: Component::Shape(crate::shape::Shape {
2246 shape: rustmotion_core::schema::ShapeType::Rect,
2247 text: None,
2248 timing: Default::default(),
2249 style: CssStyle {
2250 width: Some(CSize::Length(CLP::Px(100.0))),
2251 height: Some(CSize::Length(CLP::Px(80.0))),
2252 ..Default::default()
2253 },
2254 timeline: Vec::new(),
2255 stagger: None,
2256 fill: None,
2257 stroke: None,
2258 draw_start: None,
2259 path_morph: None,
2260 }),
2261 position: Some(crate::PositionMode::Absolute { x: 40.0, y: 30.0 }),
2262 x: None,
2263 y: None,
2264 z_index: None,
2265 bleed: false,
2266 }];
2267 let built = build_scene(&scene, (400.0, 400.0));
2268 let layout = run_layout(&built.root, (400.0, 400.0), &ConversionContext::default());
2269 let shape_id = built.root.children[0].id;
2270 let l = layout.get(shape_id).expect("shape laid out");
2271 assert_eq!(l.x, 40.0);
2272 assert_eq!(l.y, 30.0);
2273 assert_eq!(l.width, 100.0);
2274 assert_eq!(l.height, 80.0);
2275 }
2276
2277 #[test]
2278 fn horizontal_divider_stretches_to_parent_width() {
2279 let divider = ChildComponent {
2280 id: None,
2281 component: Component::Divider(crate::divider::Divider {
2282 direction: DividerDirection::Horizontal,
2283 thickness: 4.0,
2284 line_style: Default::default(),
2285 length: None,
2286 timing: Default::default(),
2287 style: CssStyle::default(),
2288 timeline: Vec::new(),
2289 stagger: None,
2290 }),
2291 position: None,
2292 x: None,
2293 y: None,
2294 z_index: None,
2295 bleed: false,
2296 };
2297 let scene = vec![divider];
2298 let built = build_scene(&scene, (800.0, 600.0));
2299 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2300 let id = built.root.children[0].id;
2301 let l = layout.get(id).expect("divider laid out");
2302 assert_eq!(l.height, 4.0);
2303 assert_eq!(l.width, 800.0);
2304 }
2305
2306 #[test]
2307 fn text_child_in_flex_card_gets_cosmic_intrinsic_size() {
2308 use crate::container::ContainerComponent;
2309 use crate::text::Text;
2310
2311 use rustmotion_core::css::units::Length;
2312
2313 let text = ChildComponent {
2314 id: None,
2315 component: Component::Text(Text {
2316 content: "Hello World".into(),
2317 max_width: None,
2318 timing: Default::default(),
2319 style: CssStyle {
2320 font_size: Some(Length::Px(40.0)),
2321 ..Default::default()
2322 },
2323 timeline: Vec::new(),
2324 stagger: None,
2325 text_shadow: None,
2326 stroke: None,
2327 text_background: None,
2328 caret: None,
2329 states: Vec::new(),
2330 swap: None,
2331 morph: None,
2332 }),
2333 position: None,
2334 x: None,
2335 y: None,
2336 z_index: None,
2337 bleed: false,
2338 };
2339
2340 let card = ChildComponent {
2341 id: None,
2342 component: Component::Container(ContainerComponent {
2343 children: vec![text],
2344 timing: Default::default(),
2345 style: CssStyle {
2346 display: Some(Display::Flex),
2347 flex_direction: Some(FlexDirection::Column),
2348 padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2349 ..Default::default()
2350 },
2351 timeline: Vec::new(),
2352 stagger: None,
2353 time_scale: None,
2354 time_offset: None,
2355 }),
2356 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2357 x: None,
2358 y: None,
2359 z_index: None,
2360 bleed: false,
2361 };
2362
2363 let scene = vec![card];
2364 let built = build_scene(&scene, (1920.0, 1080.0));
2365 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2366
2367 let card_id = built.root.children[0].id;
2368 let text_id = built.root.children[0].children[0].id;
2369 let text_layout = layout.get(text_id).expect("text laid out");
2370
2371 assert!(
2372 text_layout.width > 0.0,
2373 "text width should be > 0, got {}",
2374 text_layout.width
2375 );
2376 assert!(
2377 text_layout.height >= 40.0,
2378 "text height should be at least one line tall, got {}",
2379 text_layout.height
2380 );
2381
2382 let card_layout = layout.get(card_id).expect("card laid out");
2383 assert!(
2384 card_layout.height >= text_layout.height + 40.0 - 1.0,
2385 "card height ({}) should fit text + padding ({}+40)",
2386 card_layout.height,
2387 text_layout.height,
2388 );
2389 }
2390
2391 #[test]
2392 fn arrow_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2393 let arrow = ChildComponent {
2394 id: None,
2395 component: Component::Arrow(crate::arrow::Arrow {
2396 x1: 10.0,
2397 y1: 20.0,
2398 x2: 110.0,
2399 y2: 80.0,
2400 cp: None,
2401 cp1: None,
2402 cp2: None,
2403 curve: None,
2404 width: 4.0,
2405 color: "#fff".into(),
2406 arrow_end: true,
2407 arrow_start: false,
2408 arrow_size: 12.0,
2409 dashed: None,
2410 timing: Default::default(),
2411 style: CssStyle::default(),
2412 timeline: Vec::new(),
2413 stagger: None,
2414 }),
2415 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2416 x: None,
2417 y: None,
2418 z_index: None,
2419 bleed: false,
2420 };
2421 let scene = vec![arrow];
2422 let built = build_scene(&scene, (800.0, 600.0));
2423 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2424 let l = layout
2425 .get(built.root.children[0].id)
2426 .expect("arrow laid out");
2427 assert_eq!(l.width, 128.0);
2428 assert_eq!(l.height, 88.0);
2429 }
2430
2431 #[test]
2432 fn connector_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2433 let conn = ChildComponent {
2434 id: None,
2435 component: Component::Connector(crate::connector::Connector {
2436 from: crate::connector::ConnectorPoint { x: 50.0, y: 0.0 },
2437 to: crate::connector::ConnectorPoint { x: 150.0, y: 50.0 },
2438 routing: Default::default(),
2439 curvature: 0.4,
2440 width: 2.0,
2441 color: "#fff".into(),
2442 arrow_end: true,
2443 arrow_start: false,
2444 arrow_size: 10.0,
2445 dashed: None,
2446 timing: Default::default(),
2447 style: CssStyle::default(),
2448 timeline: Vec::new(),
2449 stagger: None,
2450 }),
2451 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2452 x: None,
2453 y: None,
2454 z_index: None,
2455 bleed: false,
2456 };
2457 let scene = vec![conn];
2458 let built = build_scene(&scene, (800.0, 600.0));
2459 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2460 let l = layout
2461 .get(built.root.children[0].id)
2462 .expect("connector laid out");
2463 assert_eq!(l.width, 126.0);
2464 assert_eq!(l.height, 76.0);
2465 }
2466
2467 #[test]
2468 fn counter_intrinsic_size_reserves_space_for_max_value() {
2469 use crate::counter::Counter;
2470
2471 use rustmotion_core::css::units::Length;
2472 let counter = ChildComponent {
2473 id: None,
2474 component: Component::Counter(Counter {
2475 duration: None,
2476 from: 0.0,
2477 to: 1234.0,
2478 decimals: 0,
2479 separator: None,
2480 prefix: None,
2481 suffix: None,
2482 easing: Default::default(),
2483 timing: Default::default(),
2484 style: CssStyle {
2485 font_size: Some(Length::Px(64.0)),
2486 ..Default::default()
2487 },
2488 timeline: Vec::new(),
2489 stagger: None,
2490 text_shadow: None,
2491 stroke: None,
2492 }),
2493 position: Some(crate::PositionMode::Absolute { x: 10.0, y: 20.0 }),
2494 x: None,
2495 y: None,
2496 z_index: None,
2497 bleed: false,
2498 };
2499 let scene = vec![counter];
2500 let built = build_scene(&scene, (800.0, 600.0));
2501 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2502 let id = built.root.children[0].id;
2503 let l = layout.get(id).expect("counter laid out");
2504 assert!(
2505 l.width > 0.0,
2506 "counter width should be > 0, got {}",
2507 l.width
2508 );
2509 assert!(
2510 l.height >= 60.0,
2511 "counter height should be ≥ ~one line ({}), got {}",
2512 64.0,
2513 l.height
2514 );
2515 }
2516
2517 #[test]
2518 fn badge_intrinsic_size_includes_padding_and_text() {
2519 use crate::badge::{Badge, BadgeSize, BadgeVariant};
2520
2521 let badge = ChildComponent {
2522 id: None,
2523 component: Component::Badge(Badge {
2524 text: "New".into(),
2525 icon: None,
2526 variant: BadgeVariant::Solid,
2527 badge_size: BadgeSize::Md,
2528 dot: false,
2529 dot_color: None,
2530 pulse: false,
2531 count: None,
2532 timing: Default::default(),
2533 style: CssStyle::default(),
2534 timeline: Vec::new(),
2535 stagger: None,
2536 }),
2537 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2538 x: None,
2539 y: None,
2540 z_index: None,
2541 bleed: false,
2542 };
2543 let scene = vec![badge];
2544 let built = build_scene(&scene, (400.0, 200.0));
2545 let layout = run_layout(&built.root, (400.0, 200.0), &ConversionContext::default());
2546 let id = built.root.children[0].id;
2547 let l = layout.get(id).expect("badge laid out");
2548 assert!(
2549 l.width > 24.0,
2550 "badge width should exceed padding alone, got {}",
2551 l.width
2552 );
2553 assert!(
2554 (l.height - 30.2).abs() < 2.0,
2555 "badge height should be ~30.2, got {}",
2556 l.height
2557 );
2558 }
2559
2560 #[test]
2561 fn line_intrinsic_size_matches_endpoint_bounding_box() {
2562 let line = ChildComponent {
2563 id: None,
2564 component: Component::Line(crate::line::Line {
2565 x1: 10.0,
2566 y1: 20.0,
2567 x2: 110.0,
2568 y2: 80.0,
2569 width: 2.0,
2570 color: "#fff".into(),
2571 dashed: None,
2572 timing: Default::default(),
2573 style: CssStyle::default(),
2574 timeline: Vec::new(),
2575 stagger: None,
2576 }),
2577 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2578 x: None,
2579 y: None,
2580 z_index: None,
2581 bleed: false,
2582 };
2583 let scene = vec![line];
2584 let built = build_scene(&scene, (800.0, 600.0));
2585 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2586 let id = built.root.children[0].id;
2587 let l = layout.get(id).expect("line laid out");
2588 assert_eq!(l.width, 100.0);
2589 assert_eq!(l.height, 60.0);
2590 }
2591
2592 #[test]
2593 fn rich_text_child_gets_a_non_zero_intrinsic_size() {
2594 use crate::rich_text::{RichText, RichTextSpan};
2595 use rustmotion_core::css::units::Length;
2596
2597 let rich_text = ChildComponent {
2598 id: None,
2599 component: Component::RichText(RichText {
2600 spans: vec![
2601 RichTextSpan {
2602 text: "Save ".into(),
2603 color: None,
2604 font_size: None,
2605 font_weight: None,
2606 font_family: None,
2607 font_style: None,
2608 letter_spacing: None,
2609 background: None,
2610 padding: None,
2611 border_radius: None,
2612 rotation: None,
2613 },
2614 RichTextSpan {
2615 text: "40%".into(),
2616 color: Some("#5C39EE".into()),
2617 font_size: None,
2618 font_weight: None,
2619 font_family: None,
2620 font_style: None,
2621 letter_spacing: None,
2622 background: None,
2623 padding: None,
2624 border_radius: None,
2625 rotation: None,
2626 },
2627 ],
2628 max_width: None,
2629 timing: Default::default(),
2630 style: CssStyle {
2631 font_size: Some(Length::Px(40.0)),
2632 ..Default::default()
2633 },
2634 timeline: Vec::new(),
2635 stagger: None,
2636 }),
2637 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2638 x: None,
2639 y: None,
2640 z_index: None,
2641 bleed: false,
2642 };
2643 let scene = vec![rich_text];
2644 let built = build_scene(&scene, (800.0, 600.0));
2645 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2646 let id = built.root.children[0].id;
2647 let l = layout.get(id).expect("rich_text laid out");
2648 assert!(
2649 l.width > 0.0,
2650 "rich_text width should be > 0, got {}",
2651 l.width
2652 );
2653 assert!(
2654 l.height > 0.0,
2655 "rich_text height should be > 0, got {}",
2656 l.height
2657 );
2658 }
2659
2660 #[test]
2661 fn glow_effect_adds_a_coloured_drop_shadow_filter() {
2662 use rustmotion_core::css::style::{Color, FilterFn};
2663 use rustmotion_core::css::units::Length as CLength;
2664 use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2665
2666 let mut shape = make_shape(100.0, 100.0);
2667 if let Component::Shape(ref mut s) = shape.component {
2668 s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2669 color: "#5C39EE".to_string(),
2670 radius: 12.0,
2671 intensity: 1.0,
2672 })];
2673 }
2674 let anim = BuildAnimationCtx {
2675 time: 0.0,
2676 scenario_time: 0.0,
2677 scene_duration: 1.0,
2678 fps: 30,
2679 };
2680 let scene = [shape];
2681 let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2682 let filters = built.root.children[0]
2683 .css
2684 .filter
2685 .as_ref()
2686 .expect("glow must add a filter list");
2687 let shadow = filters
2688 .iter()
2689 .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2690 .expect("a DropShadow filter must be present");
2691 let FilterFn::DropShadow { blur, color, .. } = shadow else {
2692 unreachable!()
2693 };
2694 match blur {
2695 Some(CLength::Px(px)) => {
2696 assert_eq!(*px, 12.0, "blur radius must match GlowConfig.radius")
2697 }
2698 other => panic!("expected Some(Length::Px(12.0)) blur, got {:?}", other),
2699 }
2700 match color {
2701 Some(Color::Rgba { r, g, b, a }) => {
2702 assert_eq!(*r, 0x5C);
2703 assert_eq!(*g, 0x39);
2704 assert_eq!(*b, 0xEE);
2705 assert!(*a > 0.0, "alpha must be > 0 for the glow to be visible");
2706 }
2707 other => panic!("expected Some(Color::Rgba{{..}}), got {:?}", other),
2708 }
2709 }
2710
2711 #[test]
2712 fn glow_effect_scales_alpha_by_intensity() {
2713 use rustmotion_core::css::style::{Color, FilterFn};
2714 use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2715
2716 let mut shape = make_shape(100.0, 100.0);
2717 if let Component::Shape(ref mut s) = shape.component {
2718 s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2719 color: "#FFFFFFFF".to_string(),
2720 radius: 10.0,
2721 intensity: 0.5,
2722 })];
2723 }
2724 let anim = BuildAnimationCtx {
2725 time: 0.0,
2726 scenario_time: 0.0,
2727 scene_duration: 1.0,
2728 fps: 30,
2729 };
2730 let scene = [shape];
2731 let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2732 let filters = built.root.children[0].css.filter.as_ref().unwrap();
2733 let FilterFn::DropShadow { color, .. } = filters
2734 .iter()
2735 .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2736 .unwrap()
2737 else {
2738 unreachable!()
2739 };
2740 let Some(Color::Rgba { a, .. }) = color else {
2741 panic!("expected Rgba color");
2742 };
2743 assert!(
2744 (*a - 0.5).abs() < 1e-4,
2745 "intensity 0.5 on an opaque colour must scale alpha to ~0.5, got {}",
2746 a
2747 );
2748 }
2749
2750 #[test]
2751 fn no_glow_effect_leaves_filter_untouched() {
2752 let shape = make_shape(100.0, 100.0);
2753 let anim = BuildAnimationCtx {
2754 time: 0.0,
2755 scenario_time: 0.0,
2756 scene_duration: 1.0,
2757 fps: 30,
2758 };
2759 let scene = [shape];
2760 let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2761 assert!(built.root.children[0].css.filter.is_none());
2762 }
2763
2764 fn child_from_json(json: serde_json::Value) -> ChildComponent {
2765 let component: Component =
2766 serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{e}\n{json:#}"));
2767 ChildComponent {
2768 id: None,
2769 component,
2770 position: None,
2771 x: None,
2772 y: None,
2773 z_index: None,
2774 bleed: false,
2775 }
2776 }
2777
2778 fn layout_in_auto_card(child_json: serde_json::Value) -> (f32, f32) {
2779 let card = make_card(vec![child_from_json(child_json)], CssStyle::default());
2780 let scene = vec![ChildComponent {
2781 id: None,
2782 component: card,
2783 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2784 x: None,
2785 y: None,
2786 z_index: None,
2787 bleed: false,
2788 }];
2789 let built = build_scene(&scene, (1920.0, 1080.0));
2790 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2791 let child_id = built.root.children[0].children[0].id;
2792 let l = layout.get(child_id).expect("child laid out");
2793 (l.width, l.height)
2794 }
2795
2796 #[test]
2797 fn all_23_unsized_components_get_a_positive_box_in_a_card() {
2798 let cases: &[(&str, serde_json::Value)] = &[
2799 ("callout", json!({"type":"callout","text":"Hello"})),
2800 (
2801 "chart",
2802 json!({"type":"chart","chart_type":"bar","data":[{"value":10}]}),
2803 ),
2804 ("comparison", json!({"type":"comparison"})),
2805 (
2806 "dot_map",
2807 json!({"type":"dot_map","points":[{"lat":10.0,"lng":10.0}]}),
2808 ),
2809 ("gauge", json!({"type":"gauge","value":50})),
2810 ("gif", json!({"type":"gif","src":"a.gif"})),
2811 ("heatmap", json!({"type":"heatmap","data":[[1.0,2.0]]})),
2812 ("icon", json!({"type":"icon","icon":"lucide:home"})),
2813 ("image", json!({"type":"image","src":"a.png"})),
2814 ("lottie", json!({"type":"lottie","data":"{}"})),
2815 ("marquee", json!({"type":"marquee","content":"hi"})),
2816 (
2817 "mockup",
2818 json!({"type":"mockup","device":"iphone","src":"a.png"}),
2819 ),
2820 ("pill_nav", json!({"type":"pill_nav","items":["A","B"]})),
2821 ("shape", json!({"type":"shape","shape":"rect"})),
2822 ("skeleton", json!({"type":"skeleton"})),
2823 (
2824 "skeleton_text",
2825 json!({"type":"skeleton","variant":"text","lines":3}),
2826 ),
2827 (
2828 "sparkline",
2829 json!({"type":"sparkline","data":[1.0,2.0,3.0]}),
2830 ),
2831 ("stat", json!({"type":"stat","value":"42"})),
2832 (
2833 "stepper",
2834 json!({"type":"stepper","steps":[{"label":"A"},{"label":"B"}]}),
2835 ),
2836 ("svg", json!({"type":"svg","data":"<svg></svg>"})),
2837 (
2838 "tag_cloud",
2839 json!({"type":"tag_cloud","tags":[{"text":"rust","weight":1.0}]}),
2840 ),
2841 ("tooltip", json!({"type":"tooltip","text":"hi"})),
2842 ("treemap", json!({"type":"treemap","data":[{"value":10.0}]})),
2843 ("video", json!({"type":"video","src":"a.mp4"})),
2844 ];
2845 for (name, json) in cases {
2846 let (w, h) = layout_in_auto_card(json.clone());
2847 assert!(w > 0.0, "{name}: width should be > 0, got {w}");
2848 assert!(h > 0.0, "{name}: height should be > 0, got {h}");
2849 }
2850 }
2851
2852 #[test]
2853 fn heatmap_intrinsic_size_matches_cell_grid_formula() {
2854 let (w, h) =
2855 layout_in_auto_card(json!({"type":"heatmap","data":[[1.0,2.0,3.0],[4.0,5.0,6.0]]}));
2856 assert_eq!(w, 48.0);
2857 assert_eq!(h, 31.0);
2858 }
2859
2860 #[test]
2861 fn sparkline_gets_the_documented_120x40_default() {
2862 let (w, h) = layout_in_auto_card(json!({"type":"sparkline","data":[1.0,2.0,3.0]}));
2863 assert_eq!(w, 120.0);
2864 assert_eq!(h, 40.0);
2865 }
2866
2867 #[test]
2868 fn stat_gets_the_documented_280x180_default() {
2869 let (w, h) = layout_in_auto_card(json!({"type":"stat","value":"42"}));
2870 assert_eq!(w, 280.0);
2871 assert_eq!(h, 180.0);
2872 }
2873
2874 #[test]
2875 fn gauge_default_size_is_square() {
2876 let (w, h) = layout_in_auto_card(json!({"type":"gauge","value":50}));
2877 assert_eq!(w, h);
2878 assert!(w > 0.0);
2879 }
2880
2881 #[test]
2882 fn pill_nav_height_promotes_its_own_declared_field() {
2883 let (_, h) = layout_in_auto_card(json!({"type":"pill_nav","items":["Overview"]}));
2884 assert_eq!(h, 44.0);
2885 }
2886
2887 #[test]
2888 fn callout_width_grows_with_its_own_text_content() {
2889 let (short_w, _) = layout_in_auto_card(json!({"type":"callout","text":"Hi"}));
2890 let (long_w, _) = layout_in_auto_card(
2891 json!({"type":"callout","text":"This is a much longer callout message"}),
2892 );
2893 assert!(
2894 long_w > short_w,
2895 "longer text should measure a wider box: short={short_w}, long={long_w}"
2896 );
2897 }
2898
2899 #[test]
2900 fn three_stats_in_a_flex_row_all_get_a_positive_width() {
2901 let stats = vec![
2902 child_from_json(json!({"type":"stat","value":"45.2K","label":"Users"})),
2903 child_from_json(json!({"type":"stat","value":"12%","label":"Growth"})),
2904 child_from_json(json!({"type":"stat","value":"$8.1M","label":"Revenue"})),
2905 ];
2906 let card = make_card(
2907 stats,
2908 CssStyle {
2909 display: Some(Display::Flex),
2910 flex_direction: Some(FlexDirection::Row),
2911 gap: Some(Gap::Uniform(LengthPercentage::Px(16.0))),
2912 ..Default::default()
2913 },
2914 );
2915 let scene = vec![ChildComponent {
2916 id: None,
2917 component: card,
2918 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2919 x: None,
2920 y: None,
2921 z_index: None,
2922 bleed: false,
2923 }];
2924 let built = build_scene(&scene, (1920.0, 1080.0));
2925 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2926 for (i, child) in built.root.children[0].children.iter().enumerate() {
2927 let l = layout.get(child.id).expect("stat laid out");
2928 assert!(
2929 l.width > 0.0,
2930 "stat #{i}: width should be > 0, got {}",
2931 l.width
2932 );
2933 assert!(
2934 l.height > 0.0,
2935 "stat #{i}: height should be > 0, got {}",
2936 l.height
2937 );
2938 }
2939 }
2940
2941 #[test]
2942 fn card_color_cascades_to_text_child_with_no_color_of_its_own() {
2943 use rustmotion_core::css::style::Color;
2944
2945 let card = make_card(
2946 vec![make_text("hello", CssStyle::default())],
2947 CssStyle {
2948 color: Some(Color::String("#ff0000".into())),
2949 ..Default::default()
2950 },
2951 );
2952 let scene = vec![ChildComponent {
2953 id: None,
2954 component: card,
2955 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2956 x: None,
2957 y: None,
2958 z_index: None,
2959 bleed: false,
2960 }];
2961 let built = build_scene(&scene, (800.0, 600.0));
2962 let text_box = &built.root.children[0].children[0];
2963 assert_eq!(
2964 text_box.css.color,
2965 Some(Color::String("#ff0000".into())),
2966 "text child declares no color of its own — it should inherit the card's"
2967 );
2968 }
2969
2970 #[test]
2971 fn text_own_color_wins_over_inherited_card_color() {
2972 use rustmotion_core::css::style::Color;
2973
2974 let card = make_card(
2975 vec![make_text(
2976 "hello",
2977 CssStyle {
2978 color: Some(Color::String("#00ff00".into())),
2979 ..Default::default()
2980 },
2981 )],
2982 CssStyle {
2983 color: Some(Color::String("#ff0000".into())),
2984 ..Default::default()
2985 },
2986 );
2987 let scene = vec![ChildComponent {
2988 id: None,
2989 component: card,
2990 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2991 x: None,
2992 y: None,
2993 z_index: None,
2994 bleed: false,
2995 }];
2996 let built = build_scene(&scene, (800.0, 600.0));
2997 let text_box = &built.root.children[0].children[0];
2998 assert_eq!(
2999 text_box.css.color,
3000 Some(Color::String("#00ff00".into())),
3001 "text child's own explicit color must win over the inherited card color"
3002 );
3003 }
3004
3005 #[test]
3006 fn card_display_does_not_cascade_to_text_child() {
3007 let card = make_card(
3008 vec![make_text("hello", CssStyle::default())],
3009 CssStyle {
3010 display: Some(Display::Flex),
3011 ..Default::default()
3012 },
3013 );
3014 let scene = vec![ChildComponent {
3015 id: None,
3016 component: card,
3017 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3018 x: None,
3019 y: None,
3020 z_index: None,
3021 bleed: false,
3022 }];
3023 let built = build_scene(&scene, (800.0, 600.0));
3024 let text_box = &built.root.children[0].children[0];
3025 assert_eq!(text_box.css.display, None);
3026 }
3027
3028 fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent {
3029 ChildComponent {
3030 id: None,
3031 component: Component::Shape(crate::shape::Shape {
3032 shape: rustmotion_core::schema::ShapeType::Rect,
3033 text: None,
3034 timing: Default::default(),
3035 style: CssStyle {
3036 width: Some(CSize::Length(CLP::Px(width))),
3037 aspect_ratio: Some(aspect_ratio),
3038 ..Default::default()
3039 },
3040 timeline: Vec::new(),
3041 stagger: None,
3042 fill: None,
3043 stroke: None,
3044 draw_start: None,
3045 path_morph: None,
3046 }),
3047 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3048 x: None,
3049 y: None,
3050 z_index: None,
3051 bleed: false,
3052 }
3053 }
3054
3055 #[test]
3056 fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() {
3057 let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)];
3058 let built = build_scene(&scene, (1920.0, 1080.0));
3059 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3060 let l = layout
3061 .get(built.root.children[0].id)
3062 .expect("shape laid out");
3063 assert!(
3064 (l.width - 400.0).abs() < 1.0,
3065 "width should stay the author's explicit 400, got {}",
3066 l.width
3067 );
3068 assert!(
3069 (l.height - 225.0).abs() < 1.0,
3070 "height should derive from width/aspect-ratio (400/1.778=225), got {}",
3071 l.height
3072 );
3073 }
3074
3075 #[test]
3076 fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() {
3077 let scene = vec![make_aspect_shape_no_width(2.0)];
3078 let built = build_scene(&scene, (1920.0, 1080.0));
3079 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3080 let l = layout
3081 .get(built.root.children[0].id)
3082 .expect("shape laid out");
3083 assert!(
3084 (l.width - 80.0).abs() < 1.0,
3085 "width should keep the natural default (80), got {}",
3086 l.width
3087 );
3088 assert!(
3089 (l.height - 40.0).abs() < 1.0,
3090 "height should derive from the default width/aspect-ratio (80/2=40), got {}",
3091 l.height
3092 );
3093 }
3094
3095 fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent {
3096 ChildComponent {
3097 id: None,
3098 component: Component::Shape(crate::shape::Shape {
3099 shape: rustmotion_core::schema::ShapeType::Rect,
3100 text: None,
3101 timing: Default::default(),
3102 style: CssStyle {
3103 aspect_ratio: Some(aspect_ratio),
3104 ..Default::default()
3105 },
3106 timeline: Vec::new(),
3107 stagger: None,
3108 fill: None,
3109 stroke: None,
3110 draw_start: None,
3111 path_morph: None,
3112 }),
3113 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3114 x: None,
3115 y: None,
3116 z_index: None,
3117 bleed: false,
3118 }
3119 }
3120
3121 use crate::legacy_dispatch::LegacyPaintDispatcher;
3122 use rustmotion_core::css::style::{AlignItems, JustifyContent};
3123 use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame};
3124
3125 #[test]
3126 fn ghost_does_not_steal_a_flex_slot() {
3127 let root_css = CssStyle {
3128 display: Some(Display::Flex),
3129 flex_direction: Some(FlexDirection::Column),
3130 align_items: Some(AlignItems::Center),
3131 justify_content: Some(JustifyContent::Center),
3132 gap: Some(Gap::Uniform(CLP::Px(20.0))),
3133 width: Some(CSize::Length(CLP::Px(640.0))),
3134 height: Some(CSize::Length(CLP::Px(360.0))),
3135 ..Default::default()
3136 };
3137 let scene = vec![
3138 child_from_json(json!({
3139 "type": "shape", "shape": "rect", "fill": "#FF4FB0",
3140 "style": { "width": 200, "height": 60, "animation": [{ "name": "motion_blur" }] }
3141 })),
3142 child_from_json(json!({
3143 "type": "shape", "shape": "rect", "fill": "#4B5BFF",
3144 "style": { "width": 200, "height": 60 }
3145 })),
3146 ];
3147 let anim = BuildAnimationCtx {
3148 time: 10.0 / 30.0,
3149 scenario_time: 10.0 / 30.0,
3150 scene_duration: 1.0,
3151 fps: 30,
3152 };
3153 let built = build_scene_at_time(&scene, (640.0, 360.0), root_css, anim);
3154 let layout = run_layout(&built.root, (640.0, 360.0), &ConversionContext::default());
3155
3156 let principals: Vec<_> = built
3157 .root
3158 .children
3159 .iter()
3160 .filter(|n| matches!(n.kind, BoxKind::Component(_)))
3161 .collect();
3162 assert_eq!(
3163 principals.len(),
3164 2,
3165 "exactly the two real elements; ghosts must not be counted among them"
3166 );
3167
3168 let first = layout.get(principals[0].id).expect("first box laid out");
3169 let second = layout.get(principals[1].id).expect("second box laid out");
3170
3171 assert!(
3172 (first.y - 110.0).abs() < 1.0,
3173 "the motion-blurred box itself must sit at the flex-resolved position (110), got {}",
3174 first.y
3175 );
3176 assert!(
3177 (second.y - 190.0).abs() < 1.0,
3178 "a motion-blurred sibling must not push the next flex item down (expected 190, \
3179 regression of #66), got {}",
3180 second.y
3181 );
3182 }
3183
3184 #[test]
3185 fn ghost_of_a_container_carries_its_nested_child() {
3186 let container_component: Component = serde_json::from_value(json!({
3187 "type": "div",
3188 "style": {
3189 "width": 200, "height": 100,
3190 "background": "#FF00FF",
3191 "animation": [{ "name": "trail", "copies": 1, "spacing": 0.05, "falloff": 1.0 }]
3192 },
3193 "children": [{
3194 "type": "shape", "shape": "rect", "fill": "#00FF00",
3195 "position": "absolute", "x": 20, "y": 20,
3196 "style": { "width": 40, "height": 40 }
3197 }]
3198 }))
3199 .expect("container deserializes");
3200 let scene = vec![ChildComponent {
3201 id: None,
3202 component: container_component,
3203 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3204 x: None,
3205 y: None,
3206 z_index: None,
3207 bleed: false,
3208 }];
3209
3210 let frame_time = 0.2;
3211 let anim = BuildAnimationCtx {
3212 time: frame_time,
3213 scenario_time: frame_time,
3214 scene_duration: 1.0,
3215 fps: 30,
3216 };
3217 let mut built = build_scene_at_time(
3218 &scene,
3219 (200.0, 100.0),
3220 default_root_css((200.0, 100.0)),
3221 anim,
3222 );
3223
3224 let ghost_index = built
3225 .root
3226 .children
3227 .iter()
3228 .position(|n| matches!(n.kind, BoxKind::Ghost(_)))
3229 .expect("a ghost was built for the trail effect");
3230 let ghost = built.root.children.remove(ghost_index);
3231 assert_eq!(
3232 ghost.children.len(),
3233 1,
3234 "the ghost of a container must carry the container's own children, not an empty subtree"
3235 );
3236
3237 let mini_root = BoxNode::container(default_root_css((200.0, 100.0)), vec![ghost]);
3238 let layout = run_layout(&mini_root, (200.0, 100.0), &ConversionContext::default());
3239
3240 let mut surface =
3241 skia_safe::surfaces::raster_n32_premul((200, 100)).expect("raster surface");
3242 let canvas = surface.canvas();
3243 let dispatcher = LegacyPaintDispatcher::for_scene(&built);
3244 let frame = PaintFrame {
3245 light: Default::default(),
3246 time: frame_time,
3247 scenario_time: frame_time,
3248 frame_index: 6,
3249 fps: 30,
3250 video_width: 200,
3251 video_height: 100,
3252 scene_duration: 1.0,
3253 camera: None,
3254 };
3255 paint_tree(canvas, &mini_root, &layout, &frame, &dispatcher);
3256
3257 let snapshot = surface.image_snapshot();
3258 let info = skia_safe::ImageInfo::new(
3259 (1, 1),
3260 skia_safe::ColorType::RGBA8888,
3261 skia_safe::AlphaType::Premul,
3262 None,
3263 );
3264 let mut buf = [0u8; 4];
3265 assert!(snapshot.read_pixels(
3266 &info,
3267 &mut buf,
3268 4,
3269 skia_safe::IPoint::new(40, 40),
3270 skia_safe::image::CachingHint::Disallow,
3271 ));
3272 assert!(
3273 buf[1] > 200 && buf[0] < 80 && buf[2] < 80,
3274 "the ghost must paint the nested green shape, not just the container's own \
3275 background; got {:?}",
3276 buf
3277 );
3278 }
3279
3280 #[test]
3281 fn ghost_of_a_measured_leaf_keeps_its_intrinsic_size() {
3282 let scene = vec![child_from_json(json!({
3283 "type": "text",
3284 "content": "TEXT",
3285 "position": "absolute", "x": 100, "y": 40,
3286 "style": {
3287 "font-size": 96, "color": "#FFFFFF",
3288 "animation": [
3289 { "name": "keyframes", "keyframes": [{ "property": "translate_x", "easing": "linear", "keyframes": [
3290 { "time": 0, "value": 1200 }, { "time": 1, "value": -1200 }] }] },
3291 { "name": "motion_blur", "samples": 4, "shutter": 1.0 }
3292 ]
3293 }
3294 }))];
3295 let anim = BuildAnimationCtx {
3296 time: 0.5,
3297 scenario_time: 0.5,
3298 scene_duration: 1.0,
3299 fps: 30,
3300 };
3301 let built = build_scene_at_time(
3302 &scene,
3303 (1280.0, 360.0),
3304 default_root_css((1280.0, 360.0)),
3305 anim,
3306 );
3307 let layout = run_layout(&built.root, (1280.0, 360.0), &ConversionContext::default());
3308
3309 let ghosts: Vec<_> = built
3310 .root
3311 .children
3312 .iter()
3313 .filter(|n| matches!(n.kind, BoxKind::Ghost(_)))
3314 .collect();
3315 assert!(
3316 !ghosts.is_empty(),
3317 "expected ghosts for a motion-blurred text node"
3318 );
3319 for g in &ghosts {
3320 assert!(
3321 g.intrinsic.is_some(),
3322 "a ghost of a measured component (text) needs its own intrinsic to size itself"
3323 );
3324 let l = layout.get(g.id).expect("ghost laid out");
3325 assert!(
3326 l.width > 0.0 && l.height > 0.0,
3327 "text ghost collapsed to a zero-size box, got {}x{}",
3328 l.width,
3329 l.height
3330 );
3331 }
3332 }
3333
3334 #[test]
3335 fn ghost_of_a_path_driven_pointer_samples_a_different_waypoint_offset() {
3336 let scene = vec![child_from_json(json!({
3337 "type": "pointer",
3338 "size": 220, "tone": "dark",
3339 "path": [
3340 { "time": 0.0, "x": 1900, "y": 1000 },
3341 { "time": 0.6, "x": 900, "y": 500 }
3342 ],
3343 "path_easing": "linear",
3344 "style": { "animation": [{ "name": "motion_blur", "samples": 4, "shutter": 3.0 }] }
3345 }))];
3346 let frame_time = 0.5;
3347 let anim = BuildAnimationCtx {
3348 time: frame_time,
3349 scenario_time: frame_time,
3350 scene_duration: 1.0,
3351 fps: 30,
3352 };
3353 let built = build_scene_at_time(
3354 &scene,
3355 (1920.0, 1080.0),
3356 default_root_css((1920.0, 1080.0)),
3357 anim,
3358 );
3359
3360 let (path, click_duration, path_easing) = match &scene[0].component {
3361 Component::Pointer(p) => (p.path.clone(), p.click_duration, p.path_easing),
3362 _ => panic!("expected a pointer component"),
3363 };
3364
3365 let ghost_ids: Vec<NodeId> = built
3366 .root
3367 .children
3368 .iter()
3369 .filter(|n| matches!(n.kind, BoxKind::Ghost(_)))
3370 .map(|n| n.id)
3371 .collect();
3372 assert!(ghost_ids.len() >= 2, "expected several ghosts");
3373
3374 let offsets: Vec<(f32, f32)> = ghost_ids
3375 .iter()
3376 .map(|&id| {
3377 let (scale, shift) = built.time_params[id as usize];
3378 let local_time = frame_time * scale + shift;
3379 crate::cursor::waypoint_offset(&path, local_time, click_duration, path_easing)
3380 })
3381 .collect();
3382
3383 let distinct = offsets
3384 .windows(2)
3385 .any(|w| (w[0].0 - w[1].0).abs() > 1.0 || (w[0].1 - w[1].1).abs() > 1.0);
3386 assert!(
3387 distinct,
3388 "ghosts of a path-driven pointer must sample different points along the path, got {:?}",
3389 offsets
3390 );
3391
3392 let live_offset =
3393 crate::cursor::waypoint_offset(&path, frame_time, click_duration, path_easing);
3394 assert!(
3395 offsets
3396 .iter()
3397 .all(|o| (o.0 - live_offset.0).abs() > 1.0 || (o.1 - live_offset.1).abs() > 1.0),
3398 "every ghost sampled the live pointer position instead of its own instant; \
3399 got {:?} vs live {:?}",
3400 offsets,
3401 live_offset
3402 );
3403 }
3404
3405 #[test]
3406 fn a_pointer_moved_by_path_actually_paints_ink_at_a_ghosts_own_position() {
3407 let scene = vec![child_from_json(json!({
3408 "type": "pointer",
3409 "size": 220, "tone": "dark",
3410 "path": [
3411 { "time": 0.0, "x": 1900, "y": 1000 },
3412 { "time": 0.6, "x": 900, "y": 500 }
3413 ],
3414 "path_easing": "linear",
3415 "style": { "animation": [{ "name": "motion_blur", "samples": 12, "shutter": 3.0 }] }
3416 }))];
3417 let frame_time = 0.5;
3418 let anim = BuildAnimationCtx {
3419 time: frame_time,
3420 scenario_time: frame_time,
3421 scene_duration: 1.0,
3422 fps: 30,
3423 };
3424 let built = build_scene_at_time(
3425 &scene,
3426 (1920.0, 1080.0),
3427 default_root_css((1920.0, 1080.0)),
3428 anim,
3429 );
3430 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3431 let mut surface = paint_scene_at(&built, &layout, 1920, 1080, frame_time);
3432
3433 let (path, click_duration, path_easing) = match &scene[0].component {
3434 Component::Pointer(p) => (p.path.clone(), p.click_duration, p.path_easing),
3435 _ => panic!("expected a pointer component"),
3436 };
3437 let (fx, fy) = (0.1946_f32, 0.4390_f32);
3438 let size = 220.0_f32;
3439 let probe_for = |offset: (f32, f32)| {
3440 (
3441 (offset.0 + fx * size).round() as i32,
3442 (offset.1 + fy * size).round() as i32,
3443 )
3444 };
3445
3446 let live_offset =
3447 crate::cursor::waypoint_offset(&path, frame_time, click_duration, path_easing);
3448 let live_probe = probe_for(live_offset);
3449 let live_alpha = read_pixel(&mut surface, live_probe.0, live_probe.1)[3];
3450 assert!(
3451 live_alpha > 200,
3452 "sanity check: the live, un-ghosted pointer must paint its own glyph at the \
3453 current path position; probed {:?}, alpha={live_alpha}",
3454 live_probe
3455 );
3456
3457 let ghost_id = built
3458 .root
3459 .children
3460 .iter()
3461 .filter(|n| matches!(n.kind, BoxKind::Ghost(_)))
3462 .map(|n| n.id)
3463 .min_by(|&a, &b| {
3464 let ta = built.time_params[a as usize];
3465 let tb = built.time_params[b as usize];
3466 (frame_time * ta.0 + ta.1)
3467 .partial_cmp(&(frame_time * tb.0 + tb.1))
3468 .unwrap()
3469 })
3470 .expect("expected at least one ghost");
3471 let (scale, shift) = built.time_params[ghost_id as usize];
3472 let farthest_ghost_time = frame_time * scale + shift;
3473 let ghost_offset =
3474 crate::cursor::waypoint_offset(&path, farthest_ghost_time, click_duration, path_easing);
3475 let ghost_probe = probe_for(ghost_offset);
3476 let ghost_alpha = read_pixel(&mut surface, ghost_probe.0, ghost_probe.1)[3];
3477 assert!(
3478 ghost_alpha > 20,
3479 "a pointer moved by `path` must be ghosted along its trajectory just like one \
3480 moved by translate_x/y keyframes — the furthest motion_blur ghost (its own local \
3481 time {farthest_ghost_time}, opacity 1/13 of the live pointer) must paint ink at \
3482 its own point along the path ({ghost_offset:?}, live is {live_offset:?}), not \
3483 stay pixel-identical (fully transparent there) to the same pointer without \
3484 motion_blur; probed {:?}, alpha={ghost_alpha}",
3485 ghost_probe
3486 );
3487
3488 let empty_alpha = read_pixel(&mut surface, 100, 100)[3];
3489 assert_eq!(
3490 empty_alpha, 0,
3491 "sanity check: a point far from every sampled instant of the path must stay \
3492 untouched background"
3493 );
3494 }
3495
3496 #[test]
3497 fn animated_blur_x_becomes_a_directional_blur_filter() {
3498 use rustmotion_core::css::style::FilterFn;
3499 use rustmotion_core::css::units::Length;
3500
3501 let scene = vec![child_from_json(json!({
3502 "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3503 "style": {
3504 "width": 100, "height": 100,
3505 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3506 { "property": "blur_x", "easing": "linear", "keyframes": [
3507 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 }
3508 ] }
3509 ] }]
3510 }
3511 }))];
3512 let anim = BuildAnimationCtx {
3513 time: 0.5,
3514 scenario_time: 0.5,
3515 scene_duration: 1.0,
3516 fps: 30,
3517 };
3518 let built = build_scene_at_time(
3519 &scene,
3520 (400.0, 400.0),
3521 default_root_css((400.0, 400.0)),
3522 anim,
3523 );
3524
3525 let filters = built.root.children[0]
3526 .css
3527 .filter
3528 .clone()
3529 .expect("a blur filter was applied");
3530 let blur = filters
3531 .iter()
3532 .find(|f| matches!(f, FilterFn::Blur { .. }))
3533 .expect("a Blur filter");
3534 match blur {
3535 FilterFn::Blur {
3536 radius,
3537 radius_x,
3538 radius_y,
3539 } => {
3540 assert!(radius.is_none(), "isotropic radius must stay unset");
3541 assert!(
3542 matches!(radius_x, Some(Length::Px(v)) if (*v - 20.0).abs() < 1.0),
3543 "expected ~20px halfway through a 0->40 linear blur_x ramp, got {:?}",
3544 radius_x
3545 );
3546 assert!(
3547 radius_y.is_none(),
3548 "blur_y was never animated, must stay unset"
3549 );
3550 }
3551 other => panic!("expected Blur, got {:?}", other),
3552 }
3553 }
3554
3555 #[test]
3556 fn motion_blur_smear_mode_produces_no_ghosts_and_a_directional_filter_instead() {
3557 use rustmotion_core::css::style::FilterFn;
3558 use rustmotion_core::css::units::Length;
3559
3560 let scene = vec![child_from_json(json!({
3561 "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3562 "position": "absolute", "x": 0, "y": 0,
3563 "style": {
3564 "width": 100, "height": 60,
3565 "animation": [
3566 { "name": "keyframes", "duration": 1.0, "keyframes": [
3567 { "property": "translate_x", "easing": "linear", "keyframes": [
3568 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3569 ] }
3570 ] },
3571 { "name": "motion_blur", "mode": "smear", "shutter": 1.0 }
3572 ]
3573 }
3574 }))];
3575 let anim = BuildAnimationCtx {
3576 time: 0.5,
3577 scenario_time: 0.5,
3578 scene_duration: 1.0,
3579 fps: 30,
3580 };
3581 let built = build_scene_at_time(
3582 &scene,
3583 (1920.0, 1080.0),
3584 default_root_css((1920.0, 1080.0)),
3585 anim,
3586 );
3587
3588 assert_eq!(
3589 built.root.children.len(),
3590 1,
3591 "smear mode must not create ghost nodes"
3592 );
3593 assert!(matches!(built.root.children[0].kind, BoxKind::Component(_)));
3594
3595 let filters = built.root.children[0]
3596 .css
3597 .filter
3598 .clone()
3599 .expect("a smear filter was applied");
3600 let blur = filters
3601 .iter()
3602 .find(|f| matches!(f, FilterFn::DirectionalBlur { .. }))
3603 .expect("a DirectionalBlur filter, not an axis-aligned Blur");
3604 match blur {
3605 FilterFn::DirectionalBlur { angle, radius } => {
3606 assert!(
3607 angle.abs() < 1.0,
3608 "a pure horizontal move must orient the streak along 0 degrees, got {angle}"
3609 );
3610 assert!(
3611 matches!(radius, Length::Px(v) if (*v - 30.0).abs() < 3.0),
3612 "900px/s over a 1/30s shutter window is a ~30px streak, got {:?}",
3613 radius
3614 );
3615 }
3616 other => panic!("expected DirectionalBlur, got {:?}", other),
3617 }
3618 }
3619
3620 #[test]
3621 fn a_diagonal_smear_orients_the_directional_blur_along_the_actual_velocity() {
3622 use rustmotion_core::css::style::FilterFn;
3623
3624 let scene = vec![child_from_json(json!({
3625 "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3626 "position": "absolute", "x": 0, "y": 0,
3627 "style": {
3628 "width": 100, "height": 60,
3629 "animation": [
3630 { "name": "keyframes", "duration": 1.0, "keyframes": [
3631 { "property": "translate_x", "easing": "linear", "keyframes": [
3632 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3633 ] },
3634 { "property": "translate_y", "easing": "linear", "keyframes": [
3635 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3636 ] }
3637 ] },
3638 { "name": "motion_blur", "mode": "smear", "shutter": 1.0 }
3639 ]
3640 }
3641 }))];
3642 let anim = BuildAnimationCtx {
3643 time: 0.5,
3644 scenario_time: 0.5,
3645 scene_duration: 1.0,
3646 fps: 30,
3647 };
3648 let built = build_scene_at_time(
3649 &scene,
3650 (1920.0, 1080.0),
3651 default_root_css((1920.0, 1080.0)),
3652 anim,
3653 );
3654
3655 let filters = built.root.children[0]
3656 .css
3657 .filter
3658 .clone()
3659 .expect("a smear filter was applied");
3660 let blur = filters
3661 .iter()
3662 .find(|f| matches!(f, FilterFn::DirectionalBlur { .. }))
3663 .expect("a DirectionalBlur filter");
3664 match blur {
3665 FilterFn::DirectionalBlur { angle, .. } => {
3666 assert!(
3667 (angle - 45.0).abs() < 1.0,
3668 "equal x and y velocity must orient the streak at 45 degrees, not the \
3669 0/90 axes a box blur would be limited to, got {angle}"
3670 );
3671 }
3672 other => panic!("expected DirectionalBlur, got {:?}", other),
3673 }
3674 }
3675
3676 fn read_pixel(surface: &mut skia_safe::Surface, x: i32, y: i32) -> [u8; 4] {
3677 let snapshot = surface.image_snapshot();
3678 let info = skia_safe::ImageInfo::new(
3679 (1, 1),
3680 skia_safe::ColorType::RGBA8888,
3681 skia_safe::AlphaType::Premul,
3682 None,
3683 );
3684 let mut buf = [0u8; 4];
3685 assert!(
3686 snapshot.read_pixels(
3687 &info,
3688 &mut buf,
3689 4,
3690 skia_safe::IPoint::new(x, y),
3691 skia_safe::image::CachingHint::Disallow,
3692 ),
3693 "pixel read should succeed"
3694 );
3695 buf
3696 }
3697
3698 fn ink_extent_x(
3699 surface: &mut skia_safe::Surface,
3700 width: i32,
3701 height: i32,
3702 y0: i32,
3703 y1: i32,
3704 ) -> Option<(i32, i32)> {
3705 let snapshot = surface.image_snapshot();
3706 let info = skia_safe::ImageInfo::new(
3707 (width, height),
3708 skia_safe::ColorType::RGBA8888,
3709 skia_safe::AlphaType::Premul,
3710 None,
3711 );
3712 let mut buf = vec![0u8; (width * height * 4) as usize];
3713 assert!(
3714 snapshot.read_pixels(
3715 &info,
3716 &mut buf,
3717 (width * 4) as usize,
3718 skia_safe::IPoint::new(0, 0),
3719 skia_safe::image::CachingHint::Disallow,
3720 ),
3721 "pixel read should succeed"
3722 );
3723 let mut min_x: Option<i32> = None;
3724 let mut max_x: Option<i32> = None;
3725 for y in y0.max(0)..y1.min(height) {
3726 for x in 0..width {
3727 let alpha = buf[((y * width + x) * 4 + 3) as usize];
3728 if alpha > 0 {
3729 min_x = Some(min_x.map_or(x, |m| m.min(x)));
3730 max_x = Some(max_x.map_or(x, |m| m.max(x)));
3731 }
3732 }
3733 }
3734 min_x.zip(max_x)
3735 }
3736
3737 fn paint_scene_at(
3738 built: &BuiltScene,
3739 layout: &rustmotion_core::engine::layout_pass::LayoutResult,
3740 width: i32,
3741 height: i32,
3742 time: f64,
3743 ) -> skia_safe::Surface {
3744 let mut surface =
3745 skia_safe::surfaces::raster_n32_premul((width, height)).expect("raster surface");
3746 let canvas = surface.canvas();
3747 let dispatcher = LegacyPaintDispatcher::for_scene(built);
3748 let frame = PaintFrame {
3749 light: Default::default(),
3750 time,
3751 scenario_time: time,
3752 frame_index: (time * 30.0).round() as u32,
3753 fps: 30,
3754 video_width: width as u32,
3755 video_height: height as u32,
3756 scene_duration: 1.0,
3757 camera: None,
3758 };
3759 paint_tree(canvas, &built.root, layout, &frame, &dispatcher);
3760 surface
3761 }
3762
3763 #[test]
3764 fn a_diagonal_smear_paints_a_streak_only_along_the_velocity_not_across_it() {
3765 const VIEWPORT: f32 = 900.0;
3766 let mut principal = child_from_json(json!({
3767 "type": "shape", "shape": "rect", "fill": "#FFFFFF",
3768 "style": {
3769 "width": 80, "height": 80,
3770 "animation": [
3771 { "name": "keyframes", "duration": 1.0, "keyframes": [
3772 { "property": "translate_x", "easing": "linear", "keyframes": [
3773 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3774 ] },
3775 { "property": "translate_y", "easing": "linear", "keyframes": [
3776 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 }
3777 ] }
3778 ] },
3779 { "name": "motion_blur", "mode": "smear", "shutter": 1.0 }
3780 ]
3781 }
3782 }));
3783 principal.position = Some(crate::PositionMode::Absolute { x: 60.0, y: 60.0 });
3784 let scene = vec![principal];
3785 let anim = BuildAnimationCtx {
3786 time: 0.5,
3787 scenario_time: 0.5,
3788 scene_duration: 1.0,
3789 fps: 30,
3790 };
3791 let built = build_scene_at_time(
3792 &scene,
3793 (VIEWPORT, VIEWPORT),
3794 default_root_css((VIEWPORT, VIEWPORT)),
3795 anim,
3796 );
3797 let layout = run_layout(
3798 &built.root,
3799 (VIEWPORT, VIEWPORT),
3800 &ConversionContext::default(),
3801 );
3802 let mut surface = paint_scene_at(&built, &layout, VIEWPORT as i32, VIEWPORT as i32, 0.5);
3803
3804 let center = (550.0_f32, 550.0_f32);
3805 let half_diagonal = 40.0 * std::f32::consts::SQRT_2;
3806 let reach = half_diagonal + 15.0;
3807 let along = (
3808 (center.0 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3809 (center.1 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3810 );
3811 let perpendicular = (
3812 (center.0 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3813 (center.1 - reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32,
3814 );
3815
3816 let along_alpha = read_pixel(&mut surface, along.0, along.1)[3];
3817 let perpendicular_alpha = read_pixel(&mut surface, perpendicular.0, perpendicular.1)[3];
3818
3819 assert!(
3820 perpendicular_alpha < 10,
3821 "a point the same distance from the box's corner but 90 degrees off the 45-degree \
3822 motion must stay unlit — a directional blur has zero spread across its own axis; \
3823 got alpha {perpendicular_alpha}"
3824 );
3825 assert!(
3826 along_alpha > 25,
3827 "the same distance along the actual 45-degree motion must carry the blur's tail, \
3828 got alpha {along_alpha}"
3829 );
3830 }
3831
3832 #[test]
3833 fn an_animation_touching_only_border_radius_reaches_the_painted_frame() {
3834 const SIZE: i32 = 200;
3835 let scene = vec![child_from_json(json!({
3836 "type": "div",
3837 "style": {
3838 "width": SIZE, "height": SIZE,
3839 "background": "#FF2D55",
3840 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3841 { "property": "border_radius", "easing": "linear", "keyframes": [
3842 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 100.0 }
3843 ] }
3844 ] }]
3845 }
3846 }))];
3847 let anim = BuildAnimationCtx {
3848 time: 1.0,
3849 scenario_time: 1.0,
3850 scene_duration: 1.0,
3851 fps: 30,
3852 };
3853 let built = build_scene_at_time(
3854 &scene,
3855 (SIZE as f32, SIZE as f32),
3856 default_root_css((SIZE as f32, SIZE as f32)),
3857 anim,
3858 );
3859 let layout = run_layout(
3860 &built.root,
3861 (SIZE as f32, SIZE as f32),
3862 &ConversionContext::default(),
3863 );
3864 let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
3865
3866 let corner = read_pixel(&mut surface, 2, 2);
3867 assert_eq!(
3868 corner[3], 0,
3869 "a 200x200 box animated to a 100px border-radius (t=1) must clip its very corner to \
3870 transparent; an animation touching only border_radius must still reach paint \
3871 through box_builder's css, got rgba {:?}",
3872 corner
3873 );
3874 }
3875
3876 #[test]
3877 fn an_animation_touching_only_clip_path_progress_reaches_the_painted_frame() {
3878 const SIZE: i32 = 200;
3879 let scene = vec![child_from_json(json!({
3880 "type": "div",
3881 "style": {
3882 "width": SIZE, "height": SIZE,
3883 "background": "#00AAFF",
3884 "clip-path": {
3885 "kind": "morph",
3886 "from": { "kind": "circle", "radius": 10 },
3887 "to": { "kind": "circle", "radius": 150 },
3888 "progress": 0
3889 },
3890 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3891 { "property": "clip_path_progress", "easing": "linear", "keyframes": [
3892 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }
3893 ] }
3894 ] }]
3895 }
3896 }))];
3897 let anim = BuildAnimationCtx {
3898 time: 1.0,
3899 scenario_time: 1.0,
3900 scene_duration: 1.0,
3901 fps: 30,
3902 };
3903 let built = build_scene_at_time(
3904 &scene,
3905 (SIZE as f32, SIZE as f32),
3906 default_root_css((SIZE as f32, SIZE as f32)),
3907 anim,
3908 );
3909 let layout = run_layout(
3910 &built.root,
3911 (SIZE as f32, SIZE as f32),
3912 &ConversionContext::default(),
3913 );
3914 let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
3915
3916 let corner = read_pixel(&mut surface, 5, 5);
3917 assert!(
3918 corner[3] > 200,
3919 "at t=1 the clip-path morph must have swept to its 150px-radius `to` circle, which \
3920 covers this corner; an animation touching only clip_path_progress must still reach \
3921 paint through box_builder's css, got rgba {:?}",
3922 corner
3923 );
3924 }
3925
3926 #[test]
3927 fn an_animation_touching_only_gap_reaches_the_painted_frame() {
3928 const W: i32 = 400;
3929 const H: i32 = 80;
3930 let scene = vec![child_from_json(json!({
3931 "type": "div",
3932 "style": {
3933 "display": "flex", "flex-direction": "row",
3934 "width": W, "height": H,
3935 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3936 { "property": "gap", "easing": "linear", "keyframes": [
3937 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 100.0 }
3938 ] }
3939 ] }]
3940 },
3941 "children": [
3942 { "type": "shape", "shape": "rect", "fill": "#00AA00",
3943 "style": { "width": 80, "height": 80 } },
3944 { "type": "shape", "shape": "rect", "fill": "#0000AA",
3945 "style": { "width": 80, "height": 80 } }
3946 ]
3947 }))];
3948 let anim = BuildAnimationCtx {
3949 time: 1.0,
3950 scenario_time: 1.0,
3951 scene_duration: 1.0,
3952 fps: 30,
3953 };
3954 let built = build_scene_at_time(
3955 &scene,
3956 (W as f32, H as f32),
3957 default_root_css((W as f32, H as f32)),
3958 anim,
3959 );
3960 let layout = run_layout(
3961 &built.root,
3962 (W as f32, H as f32),
3963 &ConversionContext::default(),
3964 );
3965 let mut surface = paint_scene_at(&built, &layout, W, H, 1.0);
3966
3967 let between = read_pixel(&mut surface, 130, 40);
3968 assert_eq!(
3969 between[3], 0,
3970 "at t=1 a 100px gap must leave a transparent strip between the two 80px shapes \
3971 (x=80..180); an animation touching only gap must still reach layout through \
3972 box_builder's css, got rgba {:?}",
3973 between
3974 );
3975 }
3976
3977 #[test]
3978 fn an_animation_touching_only_padding_reaches_the_painted_frame() {
3979 const SIZE: i32 = 200;
3980 let scene = vec![child_from_json(json!({
3981 "type": "div",
3982 "style": {
3983 "width": SIZE, "height": SIZE,
3984 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
3985 { "property": "padding", "easing": "linear", "keyframes": [
3986 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 60.0 }
3987 ] }
3988 ] }]
3989 },
3990 "children": [
3991 { "type": "shape", "shape": "rect", "fill": "#FFAA00",
3992 "style": { "width": 40, "height": 40 } }
3993 ]
3994 }))];
3995 let anim = BuildAnimationCtx {
3996 time: 1.0,
3997 scenario_time: 1.0,
3998 scene_duration: 1.0,
3999 fps: 30,
4000 };
4001 let built = build_scene_at_time(
4002 &scene,
4003 (SIZE as f32, SIZE as f32),
4004 default_root_css((SIZE as f32, SIZE as f32)),
4005 anim,
4006 );
4007 let layout = run_layout(
4008 &built.root,
4009 (SIZE as f32, SIZE as f32),
4010 &ConversionContext::default(),
4011 );
4012 let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
4013
4014 let near_origin = read_pixel(&mut surface, 10, 10);
4015 assert_eq!(
4016 near_origin[3], 0,
4017 "at t=1 a 60px padding must push the 40px child away from the container's top-left \
4018 corner; an animation touching only padding must still reach layout through \
4019 box_builder's css, got rgba {:?}",
4020 near_origin
4021 );
4022 }
4023
4024 #[test]
4025 fn an_animated_font_size_reaches_layout_not_only_paint() {
4026 let make_text = |style_extra: serde_json::Value| {
4027 child_from_json(json!({
4028 "type": "text",
4029 "content": "The quick brown fox jumps over the lazy dog",
4030 "style": style_extra
4031 }))
4032 };
4033
4034 let baseline_scene = vec![make_text(json!({
4035 "font-size": 20, "white-space": "nowrap", "align-self": "flex-start"
4036 }))];
4037 let baseline_built = build_scene(&baseline_scene, (4000.0, 400.0));
4038 let baseline_layout = run_layout(
4039 &baseline_built.root,
4040 (4000.0, 400.0),
4041 &ConversionContext::default(),
4042 );
4043 let baseline_width = baseline_layout
4044 .get(baseline_built.root.children[0].id)
4045 .expect("baseline text laid out")
4046 .width;
4047
4048 let animated_scene = vec![make_text(json!({
4049 "font-size": 20,
4050 "white-space": "nowrap",
4051 "align-self": "flex-start",
4052 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4053 { "property": "font_size", "easing": "linear", "keyframes": [
4054 { "time": 0.0, "value": 20.0 }, { "time": 1.0, "value": 120.0 }
4055 ] }
4056 ] }]
4057 }))];
4058 let anim = BuildAnimationCtx {
4059 time: 1.0,
4060 scenario_time: 1.0,
4061 scene_duration: 1.0,
4062 fps: 30,
4063 };
4064 let animated_built = build_scene_at_time(
4065 &animated_scene,
4066 (4000.0, 400.0),
4067 default_root_css((4000.0, 400.0)),
4068 anim,
4069 );
4070 let animated_layout = run_layout(
4071 &animated_built.root,
4072 (4000.0, 400.0),
4073 &ConversionContext::default(),
4074 );
4075 let animated_width = animated_layout
4076 .get(animated_built.root.children[0].id)
4077 .expect("animated text laid out")
4078 .width;
4079
4080 assert!(
4081 animated_width > baseline_width * 2.0,
4082 "at t=1 the font_size animation has reached 120px (6x the declared static 20px); \
4083 the LAYOUT width must reflect that, not just the painted glyphs — issue #426/#430's \
4084 'paint right, layout stale' symptom. baseline={baseline_width}, \
4085 animated={animated_width}"
4086 );
4087 }
4088
4089 #[test]
4090 fn an_animated_letter_spacing_on_text_reaches_layout_not_only_paint() {
4091 let make_text = |style_extra: serde_json::Value| {
4092 child_from_json(json!({
4093 "type": "text",
4094 "content": "WWWWWWWWWW",
4095 "style": style_extra
4096 }))
4097 };
4098
4099 let baseline_scene = vec![make_text(json!({
4100 "letter-spacing": 0, "font-size": 40, "white-space": "nowrap",
4101 "align-self": "flex-start"
4102 }))];
4103 let baseline_built = build_scene(&baseline_scene, (4000.0, 400.0));
4104 let baseline_layout = run_layout(
4105 &baseline_built.root,
4106 (4000.0, 400.0),
4107 &ConversionContext::default(),
4108 );
4109 let baseline_width = baseline_layout
4110 .get(baseline_built.root.children[0].id)
4111 .expect("baseline text laid out")
4112 .width;
4113
4114 let animated_scene = vec![make_text(json!({
4115 "letter-spacing": 0, "font-size": 40, "white-space": "nowrap",
4116 "align-self": "flex-start",
4117 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4118 { "property": "letter_spacing", "easing": "linear", "keyframes": [
4119 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 }
4120 ] }
4121 ] }]
4122 }))];
4123 let anim = BuildAnimationCtx {
4124 time: 1.0,
4125 scenario_time: 1.0,
4126 scene_duration: 1.0,
4127 fps: 30,
4128 };
4129 let animated_built = build_scene_at_time(
4130 &animated_scene,
4131 (4000.0, 400.0),
4132 default_root_css((4000.0, 400.0)),
4133 anim,
4134 );
4135 let animated_layout = run_layout(
4136 &animated_built.root,
4137 (4000.0, 400.0),
4138 &ConversionContext::default(),
4139 );
4140 let animated_width = animated_layout
4141 .get(animated_built.root.children[0].id)
4142 .expect("animated text laid out")
4143 .width;
4144
4145 assert!(
4146 animated_width > baseline_width + 200.0,
4147 "at t=1 a 40px letter-spacing animation must widen the measured LAYOUT box, or the \
4148 box the text was given overflows — issue #430's overflow symptom exactly. \
4149 baseline={baseline_width}, animated={animated_width}"
4150 );
4151 }
4152
4153 #[test]
4154 fn an_animated_letter_spacing_reaches_rich_text_through_the_full_pipeline() {
4155 const W: i32 = 800;
4156 const H: i32 = 120;
4157 let scene = vec![child_from_json(json!({
4158 "type": "rich_text",
4159 "spans": [{ "text": "WWWWWWWWWW", "color": "#FFFFFF" }],
4160 "style": {
4161 "letter-spacing": 0, "font-size": 60,
4162 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4163 { "property": "letter_spacing", "easing": "linear", "keyframes": [
4164 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 }
4165 ] }
4166 ] }]
4167 }
4168 }))];
4169
4170 let render_extent_at = |time: f64| -> (i32, i32) {
4171 let anim = BuildAnimationCtx {
4172 time,
4173 scenario_time: time,
4174 scene_duration: 1.0,
4175 fps: 30,
4176 };
4177 let built = build_scene_at_time(
4178 &scene,
4179 (W as f32, H as f32),
4180 default_root_css((W as f32, H as f32)),
4181 anim,
4182 );
4183 let layout = run_layout(
4184 &built.root,
4185 (W as f32, H as f32),
4186 &ConversionContext::default(),
4187 );
4188 let mut surface = paint_scene_at(&built, &layout, W, H, time);
4189 ink_extent_x(&mut surface, W, H, 0, H).expect("rich_text must paint some ink")
4190 };
4191
4192 let (start0, end0) = render_extent_at(0.0);
4193 let (start1, end1) = render_extent_at(1.0);
4194 let extent0 = end0 - start0;
4195 let extent1 = end1 - start1;
4196
4197 assert!(
4198 extent1 > extent0 + 100,
4199 "an animated style.letter-spacing (no per-span override) must widen rich_text's \
4200 painted ink extent — both the box_builder wiring and rich_text's own static/\
4201 animated fallback must be in place (issue #430); extent at t=0 was {extent0}px \
4202 [{start0},{end0}], at t=1 {extent1}px [{start1},{end1}]"
4203 );
4204 }
4205
4206 #[test]
4207 fn a_static_opacity_zero_is_overridden_by_an_active_fade_in_animation_end_to_end() {
4208 const SIZE: i32 = 100;
4209 let scene = vec![child_from_json(json!({
4210 "type": "div",
4211 "style": {
4212 "width": SIZE, "height": SIZE,
4213 "background": "#00FF00",
4214 "opacity": 0,
4215 "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [
4216 { "property": "opacity", "easing": "linear", "keyframes": [
4217 { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 0.8 }
4218 ] }
4219 ] }]
4220 }
4221 }))];
4222 let anim = BuildAnimationCtx {
4223 time: 1.0,
4224 scenario_time: 1.0,
4225 scene_duration: 1.0,
4226 fps: 30,
4227 };
4228 let built = build_scene_at_time(
4229 &scene,
4230 (SIZE as f32, SIZE as f32),
4231 default_root_css((SIZE as f32, SIZE as f32)),
4232 anim,
4233 );
4234 let layout = run_layout(
4235 &built.root,
4236 (SIZE as f32, SIZE as f32),
4237 &ConversionContext::default(),
4238 );
4239 let mut surface = paint_scene_at(&built, &layout, SIZE, SIZE, 1.0);
4240
4241 let center = read_pixel(&mut surface, SIZE / 2, SIZE / 2);
4242 assert!(
4243 center[3] > 100,
4244 "the fade-in animation resolves opacity to 0.8 at t=1; a declared `opacity: 0` \
4245 authored as the animation's own starting point must not multiply it back to zero \
4246 for the rest of the run (issue #430), got rgba {:?}",
4247 center
4248 );
4249 }
4250}