1use std::sync::Arc;
17
18use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize};
19use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP};
20use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
21use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId};
22use rustmotion_core::schema::video::{AnimationEffect, MotionBlurConfig, TrailConfig};
23
24use crate::callout::ArrowDirection as CalloutArrowDirection;
25use crate::chart::ChartType;
26use crate::divider::DividerDirection;
27use crate::mockup::MockupDevice;
28use crate::skeleton::SkeletonVariant;
29use crate::stepper::StepperOrientation;
30use crate::timeline::TimelineDirection;
31use crate::tooltip::TooltipArrow;
32use crate::{ChildComponent, Component};
33
34#[derive(Debug, Clone, Copy)]
38pub struct BuildAnimationCtx {
39 pub time: f64,
40 pub scenario_time: f64,
47 pub scene_duration: f64,
48 pub fps: u32,
52}
53
54const ARROW_BBOX_PADDING: f32 = 16.0;
56
57pub struct BuiltScene<'a> {
59 pub root: BoxNode,
61 pub components: Vec<Option<&'a ChildComponent>>,
64 pub stagger_delays: Vec<f64>,
69 pub time_params: Vec<(f64, f64)>,
74}
75
76pub fn build_scene<'a>(children: &'a [ChildComponent], viewport: (f32, f32)) -> BuiltScene<'a> {
83 build_scene_with_root(children, viewport, default_root_css(viewport))
84}
85
86pub fn build_scene_with_root<'a>(
89 children: &'a [ChildComponent],
90 viewport: (f32, f32),
91 root_css: CssStyle,
92) -> BuiltScene<'a> {
93 build_scene_from_refs(children.iter(), viewport, root_css, None)
94}
95
96pub fn build_scene_at_time<'a>(
100 children: &'a [ChildComponent],
101 viewport: (f32, f32),
102 root_css: CssStyle,
103 anim: BuildAnimationCtx,
104) -> BuiltScene<'a> {
105 build_scene_from_refs(children.iter(), viewport, root_css, Some(anim))
106}
107
108pub fn build_scene_with_anim<'a>(
111 children: &'a [ChildComponent],
112 viewport: (f32, f32),
113 anim: BuildAnimationCtx,
114) -> BuiltScene<'a> {
115 build_scene_from_refs(
116 children.iter(),
117 viewport,
118 default_root_css(viewport),
119 Some(anim),
120 )
121}
122
123pub fn build_scene_from_refs<'a, I>(
127 children: I,
128 viewport: (f32, f32),
129 mut root_css: CssStyle,
130 anim: Option<BuildAnimationCtx>,
131) -> BuiltScene<'a>
132where
133 I: IntoIterator<Item = &'a ChildComponent>,
134{
135 let mut components: Vec<Option<&'a ChildComponent>> = vec![None];
136 let mut stagger_delays: Vec<f64> = vec![0.0];
137 let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)]; let mut next_id: NodeId = 1;
139
140 let mut child_boxes = Vec::new();
141 for (i, c) in children.into_iter().enumerate() {
142 child_boxes.extend(build_child(
143 c,
144 &mut components,
145 &mut stagger_delays,
146 &mut time_params,
147 &mut next_id,
148 anim,
149 format!("/children/{i}"),
150 0.0,
151 (1.0, 0.0),
152 &root_css,
153 ));
154 }
155
156 root_css.width = Some(CSize::Length(CLP::Px(viewport.0)));
158 root_css.height = Some(CSize::Length(CLP::Px(viewport.1)));
159
160 let root = BoxNode {
161 id: 0,
162 kind: BoxKind::Container,
163 css: root_css,
164 children: child_boxes,
165 intrinsic: None,
166 source_path: None,
167 window: None,
168 };
169
170 BuiltScene {
171 root,
172 components,
173 stagger_delays,
174 time_params,
175 }
176}
177
178fn default_root_css(viewport: (f32, f32)) -> CssStyle {
179 CssStyle {
180 display: Some(rustmotion_core::css::style::Display::Flex),
181 flex_direction: Some(rustmotion_core::css::style::FlexDirection::Column),
182 width: Some(CSize::Length(CLP::Px(viewport.0))),
183 height: Some(CSize::Length(CLP::Px(viewport.1))),
184 ..Default::default()
185 }
186}
187
188fn detect_ghost_effects(
193 effects: &[AnimationEffect],
194) -> (Option<MotionBlurConfig>, Option<TrailConfig>) {
195 let mut mb: Option<MotionBlurConfig> = None;
196 let mut tr: Option<TrailConfig> = None;
197 for e in effects {
198 match e {
199 AnimationEffect::MotionBlur(c) if mb.is_none() => mb = Some(c.clone()),
200 AnimationEffect::Trail(c) if tr.is_none() => tr = Some(c.clone()),
201 _ => {}
202 }
203 }
204 (mb, tr)
205}
206
207#[allow(clippy::too_many_arguments)]
227fn build_ghosts<'a>(
228 child: &'a ChildComponent,
229 components: &mut Vec<Option<&'a ChildComponent>>,
230 stagger_delays: &mut Vec<f64>,
231 time_params: &mut Vec<(f64, f64)>,
232 next_id: &mut NodeId,
233 actx: BuildAnimationCtx,
234 stagger_delay: f64,
235 time_remap: (f64, f64),
236 effects: &[AnimationEffect],
237 parent_css: &CssStyle,
238) -> Vec<BoxNode> {
239 let (mb, tr) = detect_ghost_effects(effects);
240
241 enum Strategy {
243 MotionBlur {
244 samples: u32,
245 shutter_window: f64,
246 },
247 Trail {
248 copies: u32,
249 spacing: f64,
250 falloff: f32,
251 },
252 }
253 let strategy = if let Some(mc) = mb {
254 let samples = mc.samples.clamp(1, 16);
255 if samples <= 1 {
256 return Vec::new();
258 }
259 let shutter_window = mc.shutter / actx.fps.max(1) as f64;
260 Strategy::MotionBlur {
261 samples,
262 shutter_window,
263 }
264 } else if let Some(tc) = tr {
265 let copies = tc.copies.clamp(1, 12);
266 Strategy::Trail {
267 copies,
268 spacing: tc.spacing,
269 falloff: tc.falloff,
270 }
271 } else {
272 return Vec::new();
273 };
274
275 let base_css_for_ghost = |ghost_time: f64, ghost_opacity_scale: f32| -> CssStyle {
276 let mut css = component_css(&child.component);
278 if let Some((x, y)) = child.absolute_position() {
279 css.position = Some(Position::Absolute);
280 css.left = Some(CLP::Px(x));
281 css.top = Some(CLP::Px(y));
282 }
283 if let Some(z) = child.z_index {
284 css.z_index = Some(z);
285 }
286 rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
289 if let Some(animatable) = child.component.as_animatable() {
291 let steps = animatable.timeline_steps();
292 if steps.iter().any(|s| s.style.is_some()) {
293 let skip_opacity = css.transition.is_some();
294 apply_style_states(&mut css, steps, ghost_time - stagger_delay, skip_opacity);
295 let overrides = resolve_transition_css_overrides(
300 child.component.as_styled().style_config(),
301 steps,
302 ghost_time - stagger_delay,
303 );
304 if let Some(br) = overrides.border_radius {
305 css.border_radius = Some(br);
306 }
307 if let Some(bg) = overrides.background {
308 css.background = Some(bg);
309 }
310 }
311 }
312 let ghost_actx = BuildAnimationCtx {
314 time: ghost_time,
315 scenario_time: actx.scenario_time,
316 scene_duration: actx.scene_duration,
317 fps: actx.fps,
318 };
319 if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay) {
320 let props = resolve_props_for_effects(
321 &ghost_effects,
322 ghost_actx.time,
323 ghost_actx.scene_duration,
324 );
325 if props_has_paint_overrides(&props) {
326 apply_animated_props(&mut css, &props);
327 }
328 apply_glow_effect(&mut css, &ghost_effects);
329 carry_paint_pass_effects(&mut css, &ghost_effects);
330 }
331 let base_opacity = css.opacity.unwrap_or(1.0);
333 css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0));
334 css
335 };
336
337 let mut ghosts = Vec::new();
338
339 match strategy {
340 Strategy::MotionBlur {
341 samples,
342 shutter_window,
343 } => {
344 let ghost_opacity_scale = 1.0 / (samples + 1) as f32;
347 for i in 1..=samples {
348 let ghost_time = actx.time - (i as f64 * shutter_window / samples as f64);
349 let ghost_css = base_css_for_ghost(ghost_time, ghost_opacity_scale);
350
351 let ghost_id = *next_id;
352 *next_id += 1;
353 components.push(Some(child));
355 stagger_delays.push(stagger_delay);
356 time_params.push(time_remap);
357
358 ghosts.push(BoxNode {
359 id: ghost_id,
360 kind: BoxKind::Ghost(Arc::new(ghost_id)),
361 css: ghost_css,
362 children: Vec::new(), intrinsic: None, source_path: None,
365 window: None,
366 });
367 }
368 }
369 Strategy::Trail {
370 copies,
371 spacing,
372 falloff,
373 } => {
374 let mut trail_nodes = Vec::with_capacity(copies as usize);
379 for i in 1..=copies {
380 let ghost_time = actx.time - i as f64 * spacing;
381 let ghost_opacity_scale = falloff.powi(i as i32);
382 let ghost_css = base_css_for_ghost(ghost_time, ghost_opacity_scale);
383
384 let ghost_id = *next_id;
385 *next_id += 1;
386 components.push(Some(child));
387 stagger_delays.push(stagger_delay);
388 time_params.push(time_remap);
389
390 trail_nodes.push(BoxNode {
391 id: ghost_id,
392 kind: BoxKind::Ghost(Arc::new(ghost_id)),
393 css: ghost_css,
394 children: Vec::new(),
395 intrinsic: None,
396 source_path: None,
397 window: None,
398 });
399 }
400 trail_nodes.reverse();
402 ghosts = trail_nodes;
403 }
404 }
405
406 ghosts
407}
408
409#[allow(clippy::too_many_arguments)]
421fn build_child<'a>(
422 child: &'a ChildComponent,
423 components: &mut Vec<Option<&'a ChildComponent>>,
424 stagger_delays: &mut Vec<f64>,
425 time_params: &mut Vec<(f64, f64)>,
426 next_id: &mut NodeId,
427 anim: Option<BuildAnimationCtx>,
428 path: String,
429 stagger_delay: f64,
430 time_remap: (f64, f64),
431 parent_css: &CssStyle,
432) -> Vec<BoxNode> {
433 let local_actx = anim.map(|a| {
437 let (scale, shift) = time_remap;
438 BuildAnimationCtx {
439 time: a.time * scale + shift,
440 scenario_time: a.scenario_time,
445 scene_duration: a.scene_duration,
446 fps: a.fps,
447 }
448 });
449
450 let mut ghosts: Vec<BoxNode> = Vec::new();
454 if let Some(actx) = local_actx {
455 if let Some(effects) = effective_effects(&child.component, stagger_delay) {
456 ghosts = build_ghosts(
457 child,
458 components,
459 stagger_delays,
460 time_params,
461 next_id,
462 actx,
463 stagger_delay,
464 time_remap,
465 &effects,
466 parent_css,
467 );
468 }
469 }
470
471 let id = *next_id;
472 *next_id += 1;
473 components.push(Some(child));
474 stagger_delays.push(stagger_delay);
475 time_params.push(time_remap);
476
477 let mut css = component_css(&child.component);
478
479 if let Some((x, y)) = child.absolute_position() {
481 css.position = Some(Position::Absolute);
482 css.left = Some(CLP::Px(x));
483 css.top = Some(CLP::Px(y));
484 }
485 if let Some(z) = child.z_index {
486 css.z_index = Some(z);
487 }
488
489 rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
497
498 if let Some(animatable) = child.component.as_animatable() {
505 let steps = animatable.timeline_steps();
506 if steps.iter().any(|s| s.style.is_some()) {
507 let t = local_actx.map(|a| a.time).unwrap_or(0.0);
508 let skip_opacity = css.transition.is_some();
509 apply_style_states(&mut css, steps, t - stagger_delay, skip_opacity);
510 let overrides = resolve_transition_css_overrides(
517 child.component.as_styled().style_config(),
518 steps,
519 t - stagger_delay,
520 );
521 if let Some(br) = overrides.border_radius {
522 css.border_radius = Some(br);
523 }
524 if let Some(bg) = overrides.background {
525 css.background = Some(bg);
526 }
527 }
528 }
529
530 if let Some(actx) = local_actx {
537 if let Some(effects) = effective_effects(&child.component, stagger_delay) {
538 let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration);
539 if props_has_paint_overrides(&props) {
540 apply_animated_props(&mut css, &props);
541 }
542 apply_glow_effect(&mut css, &effects);
543 carry_paint_pass_effects(&mut css, &effects);
544 }
545 }
546
547 if let Some(ar) = css.audio_reactive.take() {
551 use rustmotion_core::css::style::{AudioReactiveProperty, AudioSource, AudioSourceTag};
552 use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache;
553
554 if let Some(actx) = local_actx {
555 let cache = audio_analysis_cache();
556 let analysis_opt = if let Some(ref src) = ar.track {
557 cache.get(src).map(|r| r.clone())
558 } else {
559 cache.iter().next().map(|r| r.value().clone())
560 };
561
562 let raw = if let Some(analysis) = analysis_opt {
563 match &ar.source {
564 AudioSource::Amplitude(AudioSourceTag::Amplitude) => {
565 analysis.amplitude_smoothed(actx.scenario_time, ar.smoothing_frames)
566 }
567 AudioSource::Band { band } => {
568 analysis.band_smoothed(actx.scenario_time, *band, ar.smoothing_frames)
569 }
570 }
571 } else {
572 0.0 };
574
575 let lerped = ar.min as f32 + raw * (ar.max - ar.min) as f32;
576
577 match ar.property {
578 AudioReactiveProperty::Opacity => {
579 let base = css.opacity.unwrap_or(1.0);
580 css.opacity = Some(base * lerped.clamp(0.0, 1.0));
581 }
582 AudioReactiveProperty::Scale => {
583 let s = lerped.max(0.0);
584 let tx = css.transform.get_or_insert_with(Vec::new);
585 tx.push(rustmotion_core::css::style::TransformFn::Scale { x: s, y: s });
586 }
587 AudioReactiveProperty::TranslateY => {
588 use rustmotion_core::css::units::LengthPercentage;
589 let tx = css.transform.get_or_insert_with(Vec::new);
590 tx.push(rustmotion_core::css::style::TransformFn::TranslateY {
591 y: LengthPercentage::Px(lerped),
592 });
593 }
594 AudioReactiveProperty::Rotation => {
595 let tx = css.transform.get_or_insert_with(Vec::new);
596 tx.push(rustmotion_core::css::style::TransformFn::Rotate { deg: lerped });
597 }
598 }
599 }
600 }
601
602 let window = child.component.as_timed().and_then(|t| {
610 let (start, end) = t.timing();
611 (start.is_some() || end.is_some()).then_some({
612 let (scale, shift) = time_remap;
613 let to_global = |t_local: f64| -> f64 {
614 if scale.abs() < 1e-10 {
615 t_local
616 } else {
617 (t_local - shift) / scale
618 }
619 };
620 rustmotion_core::engine::box_tree::PaintWindow {
621 start: start.map(|s| to_global(s + stagger_delay)),
622 end: end.map(|e| to_global(e + stagger_delay)),
623 }
624 })
625 });
626
627 let children_boxes = container_children(
628 &child.component,
629 components,
630 stagger_delays,
631 time_params,
632 next_id,
633 anim,
634 &path,
635 stagger_delay,
636 time_remap,
637 &css,
638 );
639 let intrinsic = component_intrinsic(&child.component);
640
641 let principal = BoxNode {
642 id,
643 kind: BoxKind::Component(Arc::new(id)),
644 css,
645 children: children_boxes,
646 intrinsic,
647 source_path: Some(path),
648 window,
649 };
650
651 let mut result = ghosts;
653 result.push(principal);
654 result
655}
656
657pub fn effective_effects(
663 component: &Component,
664 extra_delay: f64,
665) -> Option<std::borrow::Cow<'_, [rustmotion_core::schema::AnimationEffect]>> {
666 let animatable = component.as_animatable()?;
667 let effects = animatable.animation_effects();
668 let steps = animatable.timeline_steps();
669 let smooth_color = matches!(component, Component::Text(_) | Component::Counter(_));
672 let synthesized =
673 transition_keyframes(component.as_styled().style_config(), steps, smooth_color);
674 if steps.is_empty() && synthesized.is_empty() && extra_delay == 0.0 {
675 return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects));
676 }
677 let mut merged = effects.to_vec();
678 for step in steps {
679 for effect in &step.animation {
680 let mut e = effect.clone();
681 e.shift_delay(step.at);
682 merged.push(e);
683 }
684 }
685 merged.extend(synthesized);
686 if extra_delay != 0.0 {
687 for e in &mut merged {
688 e.shift_delay(extra_delay);
689 }
690 }
691 (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged))
692}
693
694pub(crate) fn apply_style_states(
699 css: &mut CssStyle,
700 steps: &[rustmotion_core::schema::TimelineStep],
701 t: f64,
702 skip_opacity: bool,
703) {
704 let mut due: Vec<&rustmotion_core::schema::TimelineStep> = steps
705 .iter()
706 .filter(|s| s.style.is_some() && s.at <= t)
707 .collect();
708 if due.is_empty() {
709 return;
710 }
711 due.sort_by(|a, b| a.at.total_cmp(&b.at));
712
713 let Ok(serde_json::Value::Object(mut base)) = serde_json::to_value(&*css) else {
714 return;
715 };
716 for step in due {
717 let Some(style) = step.style.as_deref() else {
718 continue;
719 };
720 let Ok(serde_json::Value::Object(state)) = serde_json::to_value(style) else {
721 continue;
722 };
723 for (k, v) in state {
724 if v.is_null() {
725 continue;
726 }
727 if k == "animation" && v.as_array().is_some_and(|a| a.is_empty()) {
728 continue;
729 }
730 if skip_opacity && k == "opacity" {
731 continue;
732 }
733 base.insert(k, v);
734 }
735 }
736 if let Ok(merged) = serde_json::from_value::<CssStyle>(serde_json::Value::Object(base)) {
737 *css = merged;
738 }
739}
740
741pub(crate) fn transition_keyframes(
748 base: &CssStyle,
749 steps: &[rustmotion_core::schema::TimelineStep],
750 smooth_color: bool,
751) -> Vec<rustmotion_core::schema::AnimationEffect> {
752 use rustmotion_core::schema::{
753 Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig,
754 };
755
756 let has_states = steps.iter().any(|s| s.style.is_some());
757 if !has_states {
758 return Vec::new();
759 }
760 let (duration, easing) = match base.transition.as_ref() {
761 Some(tr) if tr.duration() > 0.0 => (tr.duration(), tr.easing()),
762 _ => (0.001, rustmotion_core::schema::EasingType::Linear),
765 };
766 let smooth_opacity = base.transition.is_some();
767
768 let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
769 steps.iter().filter(|s| s.style.is_some()).collect();
770 sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
771
772 let base_opacity = base.opacity.unwrap_or(1.0) as f64;
773 let mut opacity_kfs: Vec<Keyframe> = Vec::new();
774 let mut color_kfs: Vec<Keyframe> = Vec::new();
775 let mut prev_opacity = base_opacity;
776 let mut prev_color = base.color.as_ref().map(|c| c.to_css_string());
777
778 let kf_num = |time: f64, v: f64| Keyframe {
779 time,
780 value: KeyframeValue::Number(v),
781 easing: None,
782 };
783 let kf_color = |time: f64, c: String| Keyframe {
784 time,
785 value: KeyframeValue::Color(c),
786 easing: None,
787 };
788 let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
791 let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
792 let start = at.max(floor);
793 let mut from = from;
794 let mut to = to;
795 from.time = start;
796 to.time = to.time.max(start + 1e-6);
797 kfs.push(from);
798 kfs.push(to);
799 };
800
801 for step in sorted {
802 let style = step.style.as_deref().unwrap();
803 if smooth_opacity && base_opacity > 1e-6 {
804 if let Some(o) = style.opacity {
805 let target = o as f64;
806 if (target - prev_opacity).abs() > 1e-6 {
807 push_pair(
808 &mut opacity_kfs,
809 step.at,
810 kf_num(step.at, prev_opacity / base_opacity),
811 kf_num(step.at + duration, target / base_opacity),
812 );
813 prev_opacity = target;
814 }
815 }
816 }
817 if smooth_color {
818 if let Some(c) = style.color.as_ref() {
819 let target = c.to_css_string();
820 if prev_color.as_ref() != Some(&target) {
821 if let Some(from) = prev_color.clone() {
822 push_pair(
823 &mut color_kfs,
824 step.at,
825 kf_color(step.at, from),
826 kf_color(step.at + duration, target.clone()),
827 );
828 }
829 prev_color = Some(target);
830 }
831 }
832 }
833 }
834
835 let mut out = Vec::new();
836 let mut push_effect = |property: &str, keyframes: Vec<Keyframe>| {
837 if keyframes.is_empty() {
838 return;
839 }
840 out.push(AnimationEffect::Keyframes(KeyframesConfig {
841 keyframes: vec![Animation {
842 property: property.to_string(),
843 keyframes,
844 easing: easing.clone(),
845 spring: None,
846 }],
847 delay: 0.0,
848 duration: 0.0,
849 repeat: false,
850 }));
851 };
852 push_effect("opacity", opacity_kfs);
853 push_effect("color", color_kfs);
854 out
855}
856
857pub(crate) struct TransitionCssOverrides {
860 pub border_radius: Option<rustmotion_core::css::style::BorderRadius>,
861 pub background: Option<rustmotion_core::css::style::Background>,
862}
863
864pub(crate) fn resolve_transition_css_overrides(
896 base: &CssStyle,
897 steps: &[rustmotion_core::schema::TimelineStep],
898 t: f64,
899) -> TransitionCssOverrides {
900 use rustmotion_core::css::style::{Background, BorderRadius, Color};
901 use rustmotion_core::css::units::LengthPercentage as CssLP;
902 use rustmotion_core::engine::animator::resolve_keyframe_track;
903 use rustmotion_core::schema::{Animation, Keyframe, KeyframeValue};
904
905 let mut out = TransitionCssOverrides {
906 border_radius: None,
907 background: None,
908 };
909 let Some(tr) = base.transition.as_ref() else {
910 return out;
911 };
912 if tr.duration() <= 0.0 {
913 return out;
914 }
915 let duration = tr.duration();
916 let easing = tr.easing();
917
918 let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> =
919 steps.iter().filter(|s| s.style.is_some()).collect();
920 sorted.sort_by(|a, b| a.at.total_cmp(&b.at));
921
922 let mut prev_radius = base
923 .border_radius
924 .as_ref()
925 .and_then(BorderRadius::absolute_px);
926 let mut prev_bg = base.background.as_ref().and_then(Background::solid_hex);
927 let mut radius_kfs: Vec<Keyframe> = Vec::new();
928 let mut bg_kfs: Vec<Keyframe> = Vec::new();
929
930 let push_pair = |kfs: &mut Vec<Keyframe>, at: f64, from: Keyframe, to: Keyframe| {
935 let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN);
936 let start = at.max(floor);
937 let mut from = from;
938 let mut to = to;
939 from.time = start;
940 to.time = to.time.max(start + 1e-6);
941 kfs.push(from);
942 kfs.push(to);
943 };
944
945 for step in sorted {
946 let style = step.style.as_deref().unwrap();
947 if let Some(br) = style.border_radius.as_ref() {
948 match br.absolute_px() {
949 Some(target) => {
950 if prev_radius != Some(target) {
951 if let Some(from) = prev_radius {
952 push_pair(
953 &mut radius_kfs,
954 step.at,
955 Keyframe {
956 time: step.at,
957 value: KeyframeValue::Number(from as f64),
958 easing: None,
959 },
960 Keyframe {
961 time: step.at + duration,
962 value: KeyframeValue::Number(target as f64),
963 easing: None,
964 },
965 );
966 }
967 prev_radius = Some(target);
968 }
969 }
970 None => prev_radius = None,
976 }
977 }
978 if let Some(bg) = style.background.as_ref() {
979 match bg.solid_hex() {
980 Some(target) => {
981 if prev_bg.as_deref() != Some(target.as_str()) {
982 if let Some(from) = prev_bg.clone() {
983 push_pair(
984 &mut bg_kfs,
985 step.at,
986 Keyframe {
987 time: step.at,
988 value: KeyframeValue::Color(from),
989 easing: None,
990 },
991 Keyframe {
992 time: step.at + duration,
993 value: KeyframeValue::Color(target.clone()),
994 easing: None,
995 },
996 );
997 }
998 prev_bg = Some(target);
999 }
1000 }
1001 None => prev_bg = None,
1002 }
1003 }
1004 }
1005
1006 if !radius_kfs.is_empty() {
1007 let anim = Animation {
1008 property: "border_radius".to_string(),
1009 keyframes: radius_kfs,
1010 easing: easing.clone(),
1011 spring: None,
1012 };
1013 if let KeyframeValue::Number(v) = resolve_keyframe_track(&anim, t) {
1014 out.border_radius = Some(BorderRadius::Uniform(CssLP::Px(v as f32)));
1015 }
1016 }
1017 if !bg_kfs.is_empty() {
1018 let anim = Animation {
1019 property: "background".to_string(),
1020 keyframes: bg_kfs,
1021 easing,
1022 spring: None,
1023 };
1024 if let KeyframeValue::Color(c) = resolve_keyframe_track(&anim, t) {
1025 out.background = Some(Background::Color(Color::String(c)));
1026 }
1027 }
1028 out
1029}
1030
1031fn props_has_paint_overrides(p: &AnimatedProperties) -> bool {
1035 p.translate_x != 0.0
1036 || p.translate_y != 0.0
1037 || (p.scale_x - 1.0).abs() > 1e-4
1038 || (p.scale_y - 1.0).abs() > 1e-4
1039 || p.rotation.abs() > 1e-3
1040 || p.rotate_x.abs() > 1e-3
1041 || p.rotate_y.abs() > 1e-3
1042 || (p.opacity - 1.0).abs() > 1e-4
1043 || p.blur > 0.0
1044 || (p.glow_radius > 0.0 && p.glow_intensity > 0.0)
1045 || p.perspective > 0.0
1046 || p.width >= 0.0
1052 || p.height >= 0.0
1053}
1054
1055fn carry_paint_pass_effects(
1066 css: &mut CssStyle,
1067 effects: &[rustmotion_core::schema::AnimationEffect],
1068) {
1069 use rustmotion_core::schema::AnimationEffect;
1070 if effects
1071 .iter()
1072 .any(|e| matches!(e, AnimationEffect::Shimmer(_)))
1073 {
1074 css.animation = effects
1075 .iter()
1076 .filter(|e| matches!(e, AnimationEffect::Shimmer(_)))
1077 .cloned()
1078 .collect();
1079 }
1080}
1081
1082fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::AnimationEffect]) {
1106 use rustmotion_core::css::style::{Color, FilterFn};
1107 use rustmotion_core::css::units::Length;
1108 use rustmotion_core::engine::animator::find_glow_effect;
1109
1110 let Some(glow) = find_glow_effect(effects) else {
1111 return;
1112 };
1113
1114 let (r, g, b, a) = rustmotion_core::engine::renderer::parse_css_color(&glow.color)
1115 .unwrap_or((255, 255, 255, 255));
1116 let alpha = ((a as f32 / 255.0) * glow.intensity.max(0.0)).clamp(0.0, 1.0);
1117 let radius = glow.radius.max(0.0);
1118 if radius <= 0.0 || alpha <= 0.0 {
1119 return;
1120 }
1121
1122 let shadow = FilterFn::DropShadow {
1123 offset_x: Length::Px(0.0),
1124 offset_y: Length::Px(0.0),
1125 blur: Some(Length::Px(radius)),
1126 color: Some(Color::Rgba { r, g, b, a: alpha }),
1127 };
1128 css.filter.get_or_insert_with(Vec::new).push(shadow);
1129}
1130
1131fn component_intrinsic(
1135 component: &Component,
1136) -> Option<Arc<dyn rustmotion_core::engine::box_tree::IntrinsicMeasure>> {
1137 use Component::*;
1138 match component {
1139 Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))),
1140 GradientText(t) => Some(Arc::new(
1141 crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t),
1142 )),
1143 Caption(c) => Some(Arc::new(crate::intrinsic::CaptionIntrinsic::from_caption(
1144 c,
1145 ))),
1146 Kbd(k) => Some(Arc::new(crate::intrinsic::KbdIntrinsic::from_kbd(k))),
1147 Counter(c) => Some(Arc::new(crate::intrinsic::CounterIntrinsic::from_counter(
1148 c,
1149 ))),
1150 NumberWheel(w) => Some(Arc::new(
1151 crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w),
1152 )),
1153 Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))),
1154 Terminal(t) => Some(Arc::new(
1155 crate::intrinsic::TerminalIntrinsic::from_terminal(t),
1156 )),
1157 Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))),
1158 Codeblock(c) => Some(Arc::new(
1159 crate::intrinsic::CodeblockIntrinsic::from_codeblock(c),
1160 )),
1161 RichText(rt) => Some(Arc::new(
1165 crate::intrinsic::RichTextIntrinsic::from_rich_text(rt),
1166 )),
1167 _ => None,
1168 }
1169}
1170
1171#[allow(clippy::too_many_arguments)]
1178fn container_children<'a>(
1179 component: &'a Component,
1180 components: &mut Vec<Option<&'a ChildComponent>>,
1181 stagger_delays: &mut Vec<f64>,
1182 time_params: &mut Vec<(f64, f64)>,
1183 next_id: &mut NodeId,
1184 anim: Option<BuildAnimationCtx>,
1185 parent_path: &str,
1186 inherited_delay: f64,
1187 time_remap: (f64, f64),
1188 parent_css: &CssStyle,
1189) -> Vec<BoxNode> {
1190 let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option<f32>, f64, f64) =
1191 match component {
1192 Component::Card(c) => (
1193 &c.children,
1194 c.stagger,
1195 c.time_scale.unwrap_or(1.0),
1196 c.time_offset.unwrap_or(0.0),
1197 ),
1198 Component::Flex(c) => (
1199 &c.children,
1200 c.stagger,
1201 c.time_scale.unwrap_or(1.0),
1202 c.time_offset.unwrap_or(0.0),
1203 ),
1204 Component::Grid(c) => (
1205 &c.children,
1206 c.stagger,
1207 c.time_scale.unwrap_or(1.0),
1208 c.time_offset.unwrap_or(0.0),
1209 ),
1210 Component::Container(c) => (
1211 &c.children,
1212 c.stagger,
1213 c.time_scale.unwrap_or(1.0),
1214 c.time_offset.unwrap_or(0.0),
1215 ),
1216 Component::Positioned(c) => (
1217 &c.children,
1218 None,
1219 c.time_scale.unwrap_or(1.0),
1220 c.time_offset.unwrap_or(0.0),
1221 ),
1222 _ => return Vec::new(),
1223 };
1224
1225 let child_scale = child_scale.max(1e-6);
1227
1228 let (scale_acc, shift_acc) = time_remap;
1234 let new_scale = child_scale * scale_acc;
1235 let new_shift = child_scale * (shift_acc - child_offset);
1236 let child_remap = (new_scale, new_shift);
1237
1238 let step = stagger.unwrap_or(0.0) as f64;
1242 let mut result = Vec::new();
1243 for (j, c) in children.iter().enumerate() {
1244 result.extend(build_child(
1245 c,
1246 components,
1247 stagger_delays,
1248 time_params,
1249 next_id,
1250 anim,
1251 format!("{parent_path}/children/{j}"),
1252 inherited_delay + j as f64 * step,
1253 child_remap,
1254 parent_css,
1255 ));
1256 }
1257 result
1258}
1259
1260fn component_css(component: &Component) -> CssStyle {
1263 let mut css = component_style(component).clone();
1264 apply_default_display(component, &mut css);
1265 apply_intrinsic_overrides(component, &mut css);
1266 css
1267}
1268
1269fn apply_default_display(component: &Component, css: &mut CssStyle) {
1273 use rustmotion_core::css::style::Display;
1274 if css.display.is_some() {
1275 return;
1276 }
1277 css.display = match component {
1278 Component::Card(_) | Component::Flex(_) | Component::Container(_) => Some(Display::Flex),
1279 Component::Grid(_) => Some(Display::Grid),
1280 _ => return,
1281 };
1282}
1283
1284fn 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, 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 = skia_safe::Font::from_typeface(typeface, font_size);
1303 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(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) {
1311 use Component::*;
1312 match component {
1313 Text(t) => {
1314 let nowrap = matches!(
1329 t.style.white_space,
1330 Some(
1331 rustmotion_core::css::style::WhiteSpace::Nowrap
1332 | rustmotion_core::css::style::WhiteSpace::Pre
1333 )
1334 );
1335 if nowrap && css.min_width.is_none() {
1336 css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1337 }
1338 }
1339 GradientText(t) => {
1346 let nowrap = matches!(
1347 t.style.white_space,
1348 Some(
1349 rustmotion_core::css::style::WhiteSpace::Nowrap
1350 | rustmotion_core::css::style::WhiteSpace::Pre
1351 )
1352 );
1353 if nowrap && css.min_width.is_none() {
1354 css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1355 }
1356 }
1357 Caption(c) => {
1358 let nowrap = matches!(
1359 c.style.white_space,
1360 Some(
1361 rustmotion_core::css::style::WhiteSpace::Nowrap
1362 | rustmotion_core::css::style::WhiteSpace::Pre
1363 )
1364 );
1365 if nowrap && css.min_width.is_none() {
1366 css.min_width = Some(CSize::Length(CLP::Px(0.0)));
1367 }
1368 }
1369 Divider(d) => match d.direction {
1370 DividerDirection::Horizontal => {
1371 if css.height.is_none() {
1374 css.height = Some(CSize::Length(CLP::Px(d.thickness)));
1375 }
1376 if css.width.is_none() {
1377 css.width = match d.length {
1378 Some(l) => Some(CSize::Length(CLP::Px(l))),
1379 None => Some(CSize::Length(CLP::String("100%".into()))),
1380 };
1381 }
1382 if css.align_self.is_none() {
1383 css.align_self = Some(AlignSelf::Stretch);
1384 }
1385 }
1386 DividerDirection::Vertical => {
1387 if css.width.is_none() {
1388 css.width = Some(CSize::Length(CLP::Px(d.thickness)));
1389 }
1390 if css.height.is_none() {
1391 css.height = match d.length {
1392 Some(l) => Some(CSize::Length(CLP::Px(l))),
1393 None => Some(CSize::Length(CLP::String("100%".into()))),
1394 };
1395 }
1396 }
1397 },
1398 Line(l) => {
1399 let w = (l.x2 - l.x1).abs().max(1.0);
1402 let h = (l.y2 - l.y1).abs().max(1.0);
1403 if css.width.is_none() {
1404 css.width = Some(CSize::Length(CLP::Px(w)));
1405 }
1406 if css.height.is_none() {
1407 css.height = Some(CSize::Length(CLP::Px(h)));
1408 }
1409 }
1410 Arrow(a) => {
1411 let pad = ARROW_BBOX_PADDING + a.arrow_size.max(0.0);
1413 let w = (a.x2 - a.x1).abs().max(1.0) + pad;
1414 let h = (a.y2 - a.y1).abs().max(1.0) + pad;
1415 if css.width.is_none() {
1416 css.width = Some(CSize::Length(CLP::Px(w)));
1417 }
1418 if css.height.is_none() {
1419 css.height = Some(CSize::Length(CLP::Px(h)));
1420 }
1421 }
1422 Connector(c) => {
1423 let pad = ARROW_BBOX_PADDING + c.arrow_size.max(0.0);
1424 let w = (c.to.x - c.from.x).abs().max(1.0) + pad;
1425 let h = (c.to.y - c.from.y).abs().max(1.0) + pad;
1426 if css.width.is_none() {
1427 css.width = Some(CSize::Length(CLP::Px(w)));
1428 }
1429 if css.height.is_none() {
1430 css.height = Some(CSize::Length(CLP::Px(h)));
1431 }
1432 }
1433 Cursor(cur) => {
1434 if css.width.is_none() {
1436 css.width = Some(CSize::Length(CLP::Px(cur.width)));
1437 }
1438 if css.height.is_none() {
1439 css.height = Some(CSize::Length(CLP::Px(cur.height)));
1440 }
1441 }
1442 SuccessCheck(c) => {
1443 apply_default_size(css, c.size, c.size);
1446 }
1447 Pointer(p) => {
1448 if css.width.is_none() {
1454 css.width = Some(CSize::Length(CLP::Px(p.size * 0.6)));
1455 }
1456 if css.height.is_none() {
1457 css.height = Some(CSize::Length(CLP::Px(p.size)));
1458 }
1459 }
1460 Particle(_) => {
1461 if css.width.is_none() {
1463 css.width = Some(CSize::Length(CLP::String("100%".into())));
1464 }
1465 if css.height.is_none() {
1466 css.height = Some(CSize::Length(CLP::String("100%".into())));
1467 }
1468 }
1469 Switch(c) => {
1470 if css.width.is_none() {
1471 css.width = Some(CSize::Length(CLP::Px(c.width)));
1472 }
1473 if css.height.is_none() {
1474 css.height = Some(CSize::Length(CLP::Px(c.height)));
1475 }
1476 }
1477 Slider(c) => {
1478 if css.width.is_none() {
1479 css.width = Some(CSize::Length(CLP::Px(c.width)));
1480 }
1481 if css.height.is_none() {
1482 css.height = Some(CSize::Length(CLP::Px(c.height)));
1483 }
1484 }
1485 Progress(c) => {
1486 if css.width.is_none() {
1487 css.width = Some(CSize::Length(CLP::Px(c.width)));
1488 }
1489 if css.height.is_none() {
1490 css.height = Some(CSize::Length(CLP::Px(c.height)));
1491 }
1492 }
1493 List(c) => {
1494 if css.width.is_none() {
1495 css.width = Some(CSize::Length(CLP::Px(c.width)));
1496 }
1497 if css.height.is_none() {
1498 let font_size = c.style.font_size_px_or(16.0);
1499 let line_height = font_size * 1.3;
1500 let n = c.items.len() as f32;
1501 let h = n * line_height + (n - 1.0).max(0.0) * c.gap;
1502 css.height = Some(CSize::Length(CLP::Px(h)));
1503 }
1504 }
1505 Timeline(c) => {
1506 if css.width.is_none() {
1507 css.width = Some(CSize::Length(CLP::Px(c.width)));
1508 }
1509 if css.height.is_none() {
1510 let r = c.node_radius;
1511 let h = match c.direction {
1512 TimelineDirection::Horizontal => r * 2.0 + c.font_size * 2.5 + 24.0,
1513 TimelineDirection::Vertical => {
1514 let n = c.steps.len().max(1) as f32;
1515 n * (r * 2.0 + 64.0)
1516 }
1517 };
1518 css.height = Some(CSize::Length(CLP::Px(h)));
1519 }
1520 }
1521 Notification(c) => {
1522 if css.width.is_none() {
1523 css.width = Some(CSize::Length(CLP::Px(c.width)));
1524 }
1525 if css.height.is_none() {
1526 let h = if c.message.is_some() { 96.0 } else { 64.0 };
1527 css.height = Some(CSize::Length(CLP::Px(h)));
1528 }
1529 }
1530 Rating(c) => {
1531 if css.width.is_none() {
1532 let count = c.max as f32;
1533 let w = count * c.size + (count - 1.0).max(0.0) * c.gap;
1534 css.width = Some(CSize::Length(CLP::Px(w)));
1535 }
1536 if css.height.is_none() {
1537 css.height = Some(CSize::Length(CLP::Px(c.size)));
1538 }
1539 }
1540 Avatar(c) => {
1541 if css.width.is_none() {
1542 css.width = Some(CSize::Length(CLP::Px(c.size)));
1543 }
1544 if css.height.is_none() {
1545 css.height = Some(CSize::Length(CLP::Px(c.size)));
1546 }
1547 }
1548 AvatarGroup(c) => {
1549 if css.width.is_none() {
1550 let visible = c.visible_count() as f32;
1551 let extra = if c.overflow_count() > 0 { 1.0 } else { 0.0 };
1552 let total = visible + extra;
1553 let step = (c.size - c.overlap).max(0.0);
1554 let w = if total <= 0.0 {
1555 0.0
1556 } else {
1557 c.size + (total - 1.0) * step
1558 };
1559 css.width = Some(CSize::Length(CLP::Px(w)));
1560 }
1561 if css.height.is_none() {
1562 css.height = Some(CSize::Length(CLP::Px(c.size)));
1563 }
1564 }
1565 QrCode(c) => {
1566 if css.width.is_none() {
1567 css.width = Some(CSize::Length(CLP::Px(c.size)));
1568 }
1569 if css.height.is_none() {
1570 css.height = Some(CSize::Length(CLP::Px(c.size)));
1571 }
1572 }
1573 Countdown(c) if (css.width.is_none() || css.height.is_none()) => {
1574 let visible = [c.show_hours, c.show_minutes, c.show_seconds]
1575 .iter()
1576 .filter(|v| **v)
1577 .count() as f32;
1578 let box_w = c.digit_size * 0.75;
1579 let box_h = c.digit_size * 1.2;
1580 let w = (visible * 2.0 * box_w) + ((visible - 1.0).max(0.0) * c.gap);
1581 if css.width.is_none() {
1582 css.width = Some(CSize::Length(CLP::Px(w)));
1583 }
1584 if css.height.is_none() {
1585 css.height = Some(CSize::Length(CLP::Px(box_h)));
1586 }
1587 }
1588 AudioSpectrum(_) => {
1589 if css.width.is_none() {
1590 css.width = Some(CSize::Length(CLP::Px(400.0)));
1591 }
1592 if css.height.is_none() {
1593 css.height = Some(CSize::Length(CLP::Px(120.0)));
1594 }
1595 }
1596 Waveform(_) => {
1597 if css.width.is_none() {
1598 css.width = Some(CSize::Length(CLP::Px(400.0)));
1599 }
1600 if css.height.is_none() {
1601 css.height = Some(CSize::Length(CLP::Px(80.0)));
1602 }
1603 }
1604
1605 Callout(t) => {
1636 let font_size = t.style.font_size_px_or(16.0);
1644 let family = t.style.font_family_or("Inter");
1645 let text_w = measure_text_line_width(&t.text, font_size, family, false);
1646 let h_pad = 12.0; let v_pad = 16.0; let line_h = font_size * 1.4; let (extra_w, extra_h) = match t.arrow_direction {
1650 CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0),
1651 CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size),
1652 };
1653 apply_default_size(
1654 css,
1655 text_w + h_pad * 2.0 + extra_w,
1656 line_h + v_pad + extra_h,
1657 );
1658 }
1659 Tooltip(t) => {
1660 let font_size = t.style.font_size_px_or(t.font_size);
1664 let family = t.style.font_family_or("Inter");
1665 let text_w = measure_text_line_width(&t.text, font_size, family, false);
1666 let h_pad = 12.0;
1667 let v_pad = 16.0;
1668 let line_h = font_size * 1.4;
1669 let (extra_w, extra_h) = match t.arrow {
1670 TooltipArrow::Left | TooltipArrow::Right => (t.arrow_size, 0.0),
1671 TooltipArrow::Top | TooltipArrow::Bottom | TooltipArrow::None => {
1672 (0.0, t.arrow_size)
1673 }
1674 };
1675 apply_default_size(
1676 css,
1677 text_w + h_pad * 2.0 + extra_w,
1678 line_h + v_pad + extra_h,
1679 );
1680 }
1681 PillNav(p) => {
1682 let font_size = p.style.font_size_px_or(14.0);
1689 let family = p.style.font_family_or("Inter");
1690 let h_pad = font_size * 1.2;
1691 let n = p.items.len() as f32;
1692 let labels_w: f32 = p
1693 .items
1694 .iter()
1695 .map(|label| measure_text_line_width(label, font_size, family, false) + h_pad * 2.0)
1696 .sum();
1697 let total_w = labels_w + p.gap * (n + 1.0).max(1.0);
1698 apply_default_size(css, total_w, p.height);
1699 }
1700 Marquee(m) => {
1701 let font_size = m.style.font_size_px_or(m.font_size);
1717 apply_default_size(css, 800.0, font_size * 2.0);
1718 }
1719 Stepper(s) => {
1720 let n = (s.steps.len().max(1)) as f32;
1726 let has_desc = s.steps.iter().any(|st| st.description.is_some());
1727 const LABEL_FS: f32 = 14.0;
1728 const DESC_FS: f32 = 11.0;
1729 let max_label_w = s
1730 .steps
1731 .iter()
1732 .map(|st| measure_text_line_width(&st.label, LABEL_FS, "Inter", false))
1733 .fold(0.0_f32, f32::max);
1734 let max_desc_w = s
1735 .steps
1736 .iter()
1737 .filter_map(|st| st.description.as_deref())
1738 .map(|d| measure_text_line_width(d, DESC_FS, "Inter", false))
1739 .fold(0.0_f32, f32::max);
1740 match s.orientation {
1741 StepperOrientation::Horizontal => {
1742 let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0);
1747 let label_h = LABEL_FS * 1.3;
1748 let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 };
1749 let h = s.node_size + 4.0 + 12.0 + label_h + desc_h;
1750 apply_default_size(css, per_step * n, h);
1751 }
1752 StepperOrientation::Vertical => {
1753 let label_w = max_label_w.max(max_desc_w);
1754 let w = s.node_size + 12.0 + label_w + 24.0;
1755 let label_block = if has_desc {
1756 LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0
1757 } else {
1758 LABEL_FS * 1.3 + 8.0
1759 };
1760 let per_step = (s.node_size * 2.0).max(label_block);
1761 apply_default_size(css, w, per_step * n);
1762 }
1763 }
1764 }
1765 TagCloud(tc) => {
1766 let n = tc.tags.len();
1775 if n > 0 {
1776 let min_w = tc.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min);
1777 let max_w = tc.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max);
1778 let range = (max_w - min_w).max(0.001);
1779 const H_GAP: f32 = 12.0;
1780 const V_GAP: f32 = 8.0;
1781 let total_w: f32 = tc
1782 .tags
1783 .iter()
1784 .map(|t| {
1785 let normalized = ((t.weight - min_w) / range) as f32;
1786 let fs =
1787 tc.min_font_size + normalized * (tc.max_font_size - tc.min_font_size);
1788 measure_text_line_width(&t.text, fs, "Inter", true) + H_GAP
1789 })
1790 .sum();
1791 const CAP_W: f32 = 600.0;
1792 let box_w = total_w.clamp(CAP_W * 0.3, CAP_W);
1793 let lines = (total_w / box_w).ceil().max(1.0);
1794 let line_h = tc.max_font_size * 1.3;
1795 let box_h = lines * line_h + (lines - 1.0).max(0.0) * V_GAP;
1796 apply_default_size(css, box_w, box_h);
1797 }
1798 }
1799 Heatmap(h) => {
1800 let rows = h.data.len();
1805 let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0);
1806 let step = h.cell_size + h.cell_gap;
1807 let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1808 let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
1809 apply_default_size(css, w, hh);
1810 }
1811 Sparkline(_) => {
1812 apply_default_size(css, 120.0, 40.0);
1816 }
1817 Stat(_) => {
1818 apply_default_size(css, 280.0, 180.0);
1825 }
1826 Gauge(g) => {
1827 const TARGET_RADIUS: f32 = 88.0;
1838 let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0);
1839 apply_default_size(css, size, size);
1840 }
1841 DotMap(_) => {
1842 apply_default_size(css, 640.0, 320.0);
1848 }
1849 Comparison(_) => {
1850 apply_default_size(css, 520.0, 280.0);
1855 }
1856 Treemap(_) => {
1857 apply_default_size(css, 416.0, 368.0);
1863 }
1864 Chart(c) => {
1865 let round = matches!(
1872 c.chart_type,
1873 ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar
1874 );
1875 let (dw, dh) = if round {
1876 (320.0, 320.0)
1877 } else {
1878 (400.0, 300.0)
1879 };
1880 apply_default_size(css, dw, dh);
1881 }
1882 Skeleton(s) => {
1883 match s.variant {
1892 SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0),
1893 SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0),
1894 SkeletonVariant::Text => {
1895 let n = s.lines.max(1) as f32;
1896 let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap;
1897 apply_default_size(css, 240.0, h);
1898 }
1899 }
1900 }
1901 Mockup(m) => {
1902 let (dw, dh) = match m.device {
1907 MockupDevice::Iphone | MockupDevice::Android => (320.0, 690.0),
1908 MockupDevice::Laptop => (640.0, 400.0),
1909 MockupDevice::Browser => (640.0, 360.0),
1910 };
1911 apply_default_size(css, dw, dh);
1912 }
1913 Icon(_) => {
1914 apply_default_size(css, 64.0, 64.0);
1920 }
1921 Svg(_) => {
1922 apply_default_size(css, 200.0, 200.0);
1928 }
1929 Shape(_) => {
1930 apply_default_size(css, 80.0, 80.0);
1937 }
1938 Image(_) => {
1939 apply_default_size(css, 400.0, 300.0);
1943 }
1944 Video(_) | Gif(_) => {
1945 apply_default_size(css, 400.0, 225.0);
1949 }
1950 Lottie(_) => {
1951 apply_default_size(css, 300.0, 300.0);
1955 }
1956
1957 _ => {}
1958 }
1959}
1960
1961fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) {
1982 let ratio = css.aspect_ratio.filter(|r| *r > 0.0);
1983 match (css.width.is_some(), css.height.is_some()) {
1984 (true, true) => {}
1985 (true, false) => {
1986 let h = fixed_px(css.width.as_ref())
1987 .zip(ratio)
1988 .map(|(w, r)| w / r)
1989 .unwrap_or(dh);
1990 css.height = Some(CSize::Length(CLP::Px(h)));
1991 }
1992 (false, true) => {
1993 let w = fixed_px(css.height.as_ref())
1994 .zip(ratio)
1995 .map(|(h, r)| h * r)
1996 .unwrap_or(dw);
1997 css.width = Some(CSize::Length(CLP::Px(w)));
1998 }
1999 (false, false) => {
2000 css.width = Some(CSize::Length(CLP::Px(dw)));
2001 let h = ratio.map(|r| dw / r).unwrap_or(dh);
2002 css.height = Some(CSize::Length(CLP::Px(h)));
2003 }
2004 }
2005}
2006
2007fn fixed_px(size: Option<&CSize>) -> Option<f32> {
2013 match size? {
2014 CSize::Length(lp) => match lp.try_parse()? {
2015 rustmotion_core::css::units::ParsedLength::Px(v) => Some(v),
2016 _ => None,
2017 },
2018 _ => None,
2019 }
2020}
2021
2022fn component_style(c: &Component) -> &CssStyle {
2024 use Component::*;
2025 match c {
2026 Text(c) => &c.style,
2027 Shape(c) => &c.style,
2028 Image(c) => &c.style,
2029 Icon(c) => &c.style,
2030 Svg(c) => &c.style,
2031 Video(c) => &c.style,
2032 Gif(c) => &c.style,
2033 Counter(c) => &c.style,
2034 Cursor(c) => &c.style,
2035 Caption(c) => &c.style,
2036 Codeblock(c) => &c.style,
2037 Connector(c) => &c.style,
2038 Avatar(c) => &c.style,
2039 AvatarGroup(c) => &c.style,
2040 Arrow(c) => &c.style,
2041 Badge(c) => &c.style,
2042 Callout(c) => &c.style,
2043 Chart(c) => &c.style,
2044 Comparison(c) => &c.style,
2045 Countdown(c) => &c.style,
2046 Divider(c) => &c.style,
2047 DotMap(c) => &c.style,
2048 Gauge(c) => &c.style,
2049 GradientText(c) => &c.style,
2050 Heatmap(c) => &c.style,
2051 Kbd(c) => &c.style,
2052 Line(c) => &c.style,
2053 List(c) => &c.style,
2054 Lottie(c) => &c.style,
2055 Marquee(c) => &c.style,
2056 Mockup(c) => &c.style,
2057 Notification(c) => &c.style,
2058 Particle(c) => &c.style,
2059 PillNav(c) => &c.style,
2060 Progress(c) => &c.style,
2061 QrCode(c) => &c.style,
2062 NumberWheel(c) => &c.style,
2063 SuccessCheck(c) => &c.style,
2064 Pointer(c) => &c.style,
2065 Rating(c) => &c.style,
2066 Skeleton(c) => &c.style,
2067 Slider(c) => &c.style,
2068 Sparkline(c) => &c.style,
2069 Stat(c) => &c.style,
2070 Stepper(c) => &c.style,
2071 Switch(c) => &c.style,
2072 RichText(c) => &c.style,
2073 Table(c) => &c.style,
2074 TagCloud(c) => &c.style,
2075 Terminal(c) => &c.style,
2076 Timeline(c) => &c.style,
2077 Tooltip(c) => &c.style,
2078 Treemap(c) => &c.style,
2079 Positioned(c) => &c.style,
2080 Flex(c) => &c.style,
2081 Grid(c) => &c.style,
2082 Card(c) => &c.style,
2083 Container(c) => &c.style,
2084 AudioSpectrum(c) => &c.style,
2085 Waveform(c) => &c.style,
2086 }
2087}
2088
2089pub fn component_kind(c: &Component) -> &'static str {
2091 use Component::*;
2092 match c {
2093 Text(_) => "text",
2094 Shape(_) => "shape",
2095 Image(_) => "image",
2096 Icon(_) => "icon",
2097 Svg(_) => "svg",
2098 Video(_) => "video",
2099 Gif(_) => "gif",
2100 Counter(_) => "counter",
2101 Cursor(_) => "cursor",
2102 Caption(_) => "caption",
2103 Codeblock(_) => "codeblock",
2104 Connector(_) => "connector",
2105 Avatar(_) => "avatar",
2106 AvatarGroup(_) => "avatar_group",
2107 Arrow(_) => "arrow",
2108 Badge(_) => "badge",
2109 Callout(_) => "callout",
2110 Chart(_) => "chart",
2111 Comparison(_) => "comparison",
2112 Countdown(_) => "countdown",
2113 Divider(_) => "divider",
2114 DotMap(_) => "dot_map",
2115 Gauge(_) => "gauge",
2116 GradientText(_) => "gradient_text",
2117 Heatmap(_) => "heatmap",
2118 Kbd(_) => "kbd",
2119 Line(_) => "line",
2120 List(_) => "list",
2121 Lottie(_) => "lottie",
2122 Marquee(_) => "marquee",
2123 Mockup(_) => "mockup",
2124 Notification(_) => "notification",
2125 Particle(_) => "particle",
2126 PillNav(_) => "pill_nav",
2127 Progress(_) => "progress",
2128 QrCode(_) => "qrcode",
2129 NumberWheel(_) => "number_wheel",
2130 SuccessCheck(_) => "success_check",
2131 Pointer(_) => "pointer",
2132 Rating(_) => "rating",
2133 Skeleton(_) => "skeleton",
2134 Slider(_) => "slider",
2135 Sparkline(_) => "sparkline",
2136 Stat(_) => "stat",
2137 Stepper(_) => "stepper",
2138 Switch(_) => "switch",
2139 RichText(_) => "rich_text",
2140 Table(_) => "table",
2141 TagCloud(_) => "tag_cloud",
2142 Terminal(_) => "terminal",
2143 Timeline(_) => "timeline",
2144 Tooltip(_) => "tooltip",
2145 Treemap(_) => "treemap",
2146 Positioned(_) => "positioned",
2147 Flex(_) => "flex",
2148 Grid(_) => "grid",
2149 Card(_) => "card",
2150 Container(_) => "container",
2151 AudioSpectrum(_) => "audio_spectrum",
2152 Waveform(_) => "waveform",
2153 }
2154}
2155
2156#[cfg(test)]
2157mod tests {
2158 use super::*;
2159 use rustmotion_core::css::style::{
2160 CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize,
2161 };
2162 use rustmotion_core::css::taffy_bridge::ConversionContext;
2163 use rustmotion_core::css::units::LengthPercentage;
2164 use rustmotion_core::css::units::LengthPercentage as CLP;
2165 use rustmotion_core::engine::layout_pass::run_layout;
2166 use serde_json::json;
2167
2168 fn make_card(children: Vec<ChildComponent>, style: CssStyle) -> Component {
2169 Component::Card(crate::card::Card {
2170 children,
2171 timing: Default::default(),
2172 style,
2173 timeline: Vec::new(),
2174 stagger: None,
2175 time_scale: None,
2176 time_offset: None,
2177 })
2178 }
2179
2180 fn make_shape(width: f32, height: f32) -> ChildComponent {
2181 ChildComponent {
2182 component: Component::Shape(crate::shape::Shape {
2183 shape: rustmotion_core::schema::ShapeType::Rect,
2184 text: None,
2185 timing: Default::default(),
2186 style: CssStyle {
2187 width: Some(CSize::Length(CLP::Px(width))),
2188 height: Some(CSize::Length(CLP::Px(height))),
2189 ..Default::default()
2190 },
2191 timeline: Vec::new(),
2192 stagger: None,
2193 fill: None,
2194 stroke: None,
2195 }),
2196 position: None,
2197 x: None,
2198 y: None,
2199 z_index: None,
2200 bleed: false,
2201 }
2202 }
2203
2204 fn make_text(content: &str, style: CssStyle) -> ChildComponent {
2205 ChildComponent {
2206 component: Component::Text(crate::text::Text {
2207 content: content.to_string(),
2208 max_width: None,
2209 timing: Default::default(),
2210 style,
2211 timeline: Vec::new(),
2212 stagger: None,
2213 text_shadow: None,
2214 stroke: None,
2215 text_background: None,
2216 caret: None,
2217 states: Vec::new(),
2218 swap: None,
2219 }),
2220 position: None,
2221 x: None,
2222 y: None,
2223 z_index: None,
2224 bleed: false,
2225 }
2226 }
2227
2228 #[test]
2229 fn empty_scene_has_only_root() {
2230 let built = build_scene(&[], (1920.0, 1080.0));
2231 assert_eq!(built.root.children.len(), 0);
2232 assert_eq!(built.components.len(), 1); }
2234
2235 #[test]
2236 fn component_kind_labels() {
2237 assert_eq!(component_kind(&make_shape(100.0, 50.0).component), "shape");
2238 }
2239
2240 #[test]
2241 fn build_child_records_source_path() {
2242 let card = make_card(
2243 vec![make_shape(10.0, 10.0), make_shape(10.0, 10.0)],
2244 CssStyle::default(),
2245 );
2246 let scene = vec![ChildComponent {
2247 component: card,
2248 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2249 x: None,
2250 y: None,
2251 z_index: None,
2252 bleed: false,
2253 }];
2254 let built = build_scene(&scene, (800.0, 600.0));
2255 let card_box = &built.root.children[0];
2256 assert_eq!(card_box.source_path.as_deref(), Some("/children/0"));
2257 assert_eq!(
2258 card_box.children[1].source_path.as_deref(),
2259 Some("/children/0/children/1")
2260 );
2261 }
2262
2263 #[test]
2264 fn flex_card_with_two_shapes_lays_out_vertically() {
2265 let card = make_card(
2266 vec![make_shape(200.0, 50.0), make_shape(200.0, 50.0)],
2267 CssStyle {
2268 display: Some(Display::Flex),
2269 flex_direction: Some(FlexDirection::Column),
2270 gap: Some(Gap::Uniform(LengthPercentage::Px(10.0))),
2271 padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2272 width: Some(CSize::Length(CLP::Px(300.0))),
2273 height: Some(CSize::Length(CLP::Px(200.0))),
2274 ..Default::default()
2275 },
2276 );
2277 let scene = vec![ChildComponent {
2278 component: card,
2279 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2280 x: None,
2281 y: None,
2282 z_index: None,
2283 bleed: false,
2284 }];
2285 let built = build_scene(&scene, (1920.0, 1080.0));
2286 assert_eq!(built.root.children.len(), 1);
2287 let card_box = &built.root.children[0];
2288 assert_eq!(card_box.css.display, Some(Display::Flex));
2289 assert_eq!(card_box.css.flex_direction, Some(FlexDirection::Column));
2290 assert_eq!(card_box.children.len(), 2);
2291
2292 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2293 let card_layout = layout.get(card_box.id).expect("card laid out");
2294 assert_eq!(card_layout.x, 0.0);
2295 assert_eq!(card_layout.y, 0.0);
2296 assert_eq!(card_layout.width, 300.0);
2297 assert_eq!(card_layout.height, 200.0);
2298
2299 let c1 = layout
2300 .get(card_box.children[0].id)
2301 .expect("shape 1 laid out");
2302 let c2 = layout
2303 .get(card_box.children[1].id)
2304 .expect("shape 2 laid out");
2305 assert_eq!(c1.x, 20.0);
2307 assert_eq!(c1.y, 20.0);
2308 assert_eq!(c2.x, 20.0);
2309 assert_eq!(c2.y, 80.0);
2310 }
2311
2312 #[test]
2313 fn absolute_child_uses_top_left() {
2314 let scene = vec![ChildComponent {
2315 component: Component::Shape(crate::shape::Shape {
2316 shape: rustmotion_core::schema::ShapeType::Rect,
2317 text: None,
2318 timing: Default::default(),
2319 style: CssStyle {
2320 width: Some(CSize::Length(CLP::Px(100.0))),
2321 height: Some(CSize::Length(CLP::Px(80.0))),
2322 ..Default::default()
2323 },
2324 timeline: Vec::new(),
2325 stagger: None,
2326 fill: None,
2327 stroke: None,
2328 }),
2329 position: Some(crate::PositionMode::Absolute { x: 40.0, y: 30.0 }),
2330 x: None,
2331 y: None,
2332 z_index: None,
2333 bleed: false,
2334 }];
2335 let built = build_scene(&scene, (400.0, 400.0));
2336 let layout = run_layout(&built.root, (400.0, 400.0), &ConversionContext::default());
2337 let shape_id = built.root.children[0].id;
2338 let l = layout.get(shape_id).expect("shape laid out");
2339 assert_eq!(l.x, 40.0);
2340 assert_eq!(l.y, 30.0);
2341 assert_eq!(l.width, 100.0);
2342 assert_eq!(l.height, 80.0);
2343 }
2344
2345 #[test]
2346 fn horizontal_divider_stretches_to_parent_width() {
2347 let divider = ChildComponent {
2348 component: Component::Divider(crate::divider::Divider {
2349 direction: DividerDirection::Horizontal,
2350 thickness: 4.0,
2351 line_style: Default::default(),
2352 length: None,
2353 timing: Default::default(),
2354 style: CssStyle::default(),
2355 timeline: Vec::new(),
2356 stagger: None,
2357 }),
2358 position: None,
2359 x: None,
2360 y: None,
2361 z_index: None,
2362 bleed: false,
2363 };
2364 let scene = vec![divider];
2365 let built = build_scene(&scene, (800.0, 600.0));
2366 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2367 let id = built.root.children[0].id;
2368 let l = layout.get(id).expect("divider laid out");
2369 assert_eq!(l.height, 4.0);
2370 assert_eq!(l.width, 800.0);
2371 }
2372
2373 #[test]
2374 fn text_child_in_flex_card_gets_cosmic_intrinsic_size() {
2375 use crate::card::Card;
2379 use crate::text::Text;
2380
2381 use rustmotion_core::css::units::Length;
2382
2383 let text = ChildComponent {
2384 component: Component::Text(Text {
2385 content: "Hello World".into(),
2386 max_width: None,
2387 timing: Default::default(),
2388 style: CssStyle {
2389 font_size: Some(Length::Px(40.0)),
2390 ..Default::default()
2391 },
2392 timeline: Vec::new(),
2393 stagger: None,
2394 text_shadow: None,
2395 stroke: None,
2396 text_background: None,
2397 caret: None,
2398 states: Vec::new(),
2399 swap: None,
2400 }),
2401 position: None,
2402 x: None,
2403 y: None,
2404 z_index: None,
2405 bleed: false,
2406 };
2407
2408 let card = ChildComponent {
2409 component: Component::Card(Card {
2410 children: vec![text],
2411 timing: Default::default(),
2412 style: CssStyle {
2413 display: Some(Display::Flex),
2414 flex_direction: Some(FlexDirection::Column),
2415 padding: Some(Edges::Uniform(LengthPercentage::Px(20.0))),
2416 ..Default::default()
2417 },
2418 timeline: Vec::new(),
2419 stagger: None,
2420 time_scale: None,
2421 time_offset: None,
2422 }),
2423 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2424 x: None,
2425 y: None,
2426 z_index: None,
2427 bleed: false,
2428 };
2429
2430 let scene = vec![card];
2431 let built = build_scene(&scene, (1920.0, 1080.0));
2432 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2433
2434 let card_id = built.root.children[0].id;
2435 let text_id = built.root.children[0].children[0].id;
2436 let text_layout = layout.get(text_id).expect("text laid out");
2437
2438 assert!(
2439 text_layout.width > 0.0,
2440 "text width should be > 0, got {}",
2441 text_layout.width
2442 );
2443 assert!(
2444 text_layout.height >= 40.0,
2445 "text height should be at least one line tall, got {}",
2446 text_layout.height
2447 );
2448
2449 let card_layout = layout.get(card_id).expect("card laid out");
2451 assert!(
2452 card_layout.height >= text_layout.height + 40.0 - 1.0,
2453 "card height ({}) should fit text + padding ({}+40)",
2454 card_layout.height,
2455 text_layout.height,
2456 );
2457 }
2458
2459 #[test]
2460 fn arrow_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2461 let arrow = ChildComponent {
2462 component: Component::Arrow(crate::arrow::Arrow {
2463 x1: 10.0,
2464 y1: 20.0,
2465 x2: 110.0,
2466 y2: 80.0,
2467 cp: None,
2468 cp1: None,
2469 cp2: None,
2470 curve: None,
2471 width: 4.0,
2472 color: "#fff".into(),
2473 arrow_end: true,
2474 arrow_start: false,
2475 arrow_size: 12.0,
2476 dashed: None,
2477 timing: Default::default(),
2478 style: CssStyle::default(),
2479 timeline: Vec::new(),
2480 stagger: None,
2481 }),
2482 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2483 x: None,
2484 y: None,
2485 z_index: None,
2486 bleed: false,
2487 };
2488 let scene = vec![arrow];
2489 let built = build_scene(&scene, (800.0, 600.0));
2490 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2491 let l = layout
2492 .get(built.root.children[0].id)
2493 .expect("arrow laid out");
2494 assert_eq!(l.width, 128.0);
2496 assert_eq!(l.height, 88.0);
2497 }
2498
2499 #[test]
2500 fn connector_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() {
2501 let conn = ChildComponent {
2502 component: Component::Connector(crate::connector::Connector {
2503 from: crate::connector::ConnectorPoint { x: 50.0, y: 0.0 },
2504 to: crate::connector::ConnectorPoint { x: 150.0, y: 50.0 },
2505 routing: Default::default(),
2506 curvature: 0.4,
2507 width: 2.0,
2508 color: "#fff".into(),
2509 arrow_end: true,
2510 arrow_start: false,
2511 arrow_size: 10.0,
2512 dashed: None,
2513 timing: Default::default(),
2514 style: CssStyle::default(),
2515 timeline: Vec::new(),
2516 stagger: None,
2517 }),
2518 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2519 x: None,
2520 y: None,
2521 z_index: None,
2522 bleed: false,
2523 };
2524 let scene = vec![conn];
2525 let built = build_scene(&scene, (800.0, 600.0));
2526 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2527 let l = layout
2528 .get(built.root.children[0].id)
2529 .expect("connector laid out");
2530 assert_eq!(l.width, 126.0);
2532 assert_eq!(l.height, 76.0);
2533 }
2534
2535 #[test]
2536 fn counter_intrinsic_size_reserves_space_for_max_value() {
2537 use crate::counter::Counter;
2540
2541 use rustmotion_core::css::units::Length;
2542 let counter = ChildComponent {
2543 component: Component::Counter(Counter {
2544 duration: None,
2545 from: 0.0,
2546 to: 1234.0,
2547 decimals: 0,
2548 separator: None,
2549 prefix: None,
2550 suffix: None,
2551 easing: Default::default(),
2552 timing: Default::default(),
2553 style: CssStyle {
2554 font_size: Some(Length::Px(64.0)),
2555 ..Default::default()
2556 },
2557 timeline: Vec::new(),
2558 stagger: None,
2559 text_shadow: None,
2560 stroke: None,
2561 }),
2562 position: Some(crate::PositionMode::Absolute { x: 10.0, y: 20.0 }),
2563 x: None,
2564 y: None,
2565 z_index: None,
2566 bleed: false,
2567 };
2568 let scene = vec![counter];
2569 let built = build_scene(&scene, (800.0, 600.0));
2570 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2571 let id = built.root.children[0].id;
2572 let l = layout.get(id).expect("counter laid out");
2573 assert!(
2574 l.width > 0.0,
2575 "counter width should be > 0, got {}",
2576 l.width
2577 );
2578 assert!(
2580 l.height >= 60.0,
2581 "counter height should be ≥ ~one line ({}), got {}",
2582 64.0,
2583 l.height
2584 );
2585 }
2586
2587 #[test]
2588 fn badge_intrinsic_size_includes_padding_and_text() {
2589 use crate::badge::{Badge, BadgeSize, BadgeVariant};
2592
2593 let badge = ChildComponent {
2594 component: Component::Badge(Badge {
2595 text: "New".into(),
2596 icon: None,
2597 variant: BadgeVariant::Solid,
2598 badge_size: BadgeSize::Md,
2599 dot: false,
2600 dot_color: None,
2601 pulse: false,
2602 count: None,
2603 timing: Default::default(),
2604 style: CssStyle::default(),
2605 timeline: Vec::new(),
2606 stagger: None,
2607 }),
2608 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2609 x: None,
2610 y: None,
2611 z_index: None,
2612 bleed: false,
2613 };
2614 let scene = vec![badge];
2615 let built = build_scene(&scene, (400.0, 200.0));
2616 let layout = run_layout(&built.root, (400.0, 200.0), &ConversionContext::default());
2617 let id = built.root.children[0].id;
2618 let l = layout.get(id).expect("badge laid out");
2619 assert!(
2621 l.width > 24.0,
2622 "badge width should exceed padding alone, got {}",
2623 l.width
2624 );
2625 assert!(
2626 (l.height - 30.2).abs() < 2.0,
2627 "badge height should be ~30.2, got {}",
2628 l.height
2629 );
2630 }
2631
2632 #[test]
2633 fn line_intrinsic_size_matches_endpoint_bounding_box() {
2634 let line = ChildComponent {
2635 component: Component::Line(crate::line::Line {
2636 x1: 10.0,
2637 y1: 20.0,
2638 x2: 110.0,
2639 y2: 80.0,
2640 width: 2.0,
2641 color: "#fff".into(),
2642 dashed: None,
2643 timing: Default::default(),
2644 style: CssStyle::default(),
2645 timeline: Vec::new(),
2646 stagger: None,
2647 }),
2648 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2649 x: None,
2650 y: None,
2651 z_index: None,
2652 bleed: false,
2653 };
2654 let scene = vec![line];
2655 let built = build_scene(&scene, (800.0, 600.0));
2656 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2657 let id = built.root.children[0].id;
2658 let l = layout.get(id).expect("line laid out");
2659 assert_eq!(l.width, 100.0);
2660 assert_eq!(l.height, 60.0);
2661 }
2662
2663 #[test]
2666 fn rich_text_child_gets_a_non_zero_intrinsic_size() {
2667 use crate::rich_text::{RichText, RichTextSpan};
2670 use rustmotion_core::css::units::Length;
2671
2672 let rich_text = ChildComponent {
2673 component: Component::RichText(RichText {
2674 spans: vec![
2675 RichTextSpan {
2676 text: "Save ".into(),
2677 color: None,
2678 font_size: None,
2679 font_weight: None,
2680 font_family: None,
2681 font_style: None,
2682 letter_spacing: None,
2683 },
2684 RichTextSpan {
2685 text: "40%".into(),
2686 color: Some("#5C39EE".into()),
2687 font_size: None,
2688 font_weight: None,
2689 font_family: None,
2690 font_style: None,
2691 letter_spacing: None,
2692 },
2693 ],
2694 max_width: None,
2695 timing: Default::default(),
2696 style: CssStyle {
2697 font_size: Some(Length::Px(40.0)),
2698 ..Default::default()
2699 },
2700 timeline: Vec::new(),
2701 stagger: None,
2702 }),
2703 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2704 x: None,
2705 y: None,
2706 z_index: None,
2707 bleed: false,
2708 };
2709 let scene = vec![rich_text];
2710 let built = build_scene(&scene, (800.0, 600.0));
2711 let layout = run_layout(&built.root, (800.0, 600.0), &ConversionContext::default());
2712 let id = built.root.children[0].id;
2713 let l = layout.get(id).expect("rich_text laid out");
2714 assert!(
2715 l.width > 0.0,
2716 "rich_text width should be > 0, got {}",
2717 l.width
2718 );
2719 assert!(
2720 l.height > 0.0,
2721 "rich_text height should be > 0, got {}",
2722 l.height
2723 );
2724 }
2725
2726 #[test]
2729 fn glow_effect_adds_a_coloured_drop_shadow_filter() {
2730 use rustmotion_core::css::style::{Color, FilterFn};
2731 use rustmotion_core::css::units::Length as CLength;
2732 use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2733
2734 let mut shape = make_shape(100.0, 100.0);
2735 if let Component::Shape(ref mut s) = shape.component {
2736 s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2737 color: "#5C39EE".to_string(),
2738 radius: 12.0,
2739 intensity: 1.0,
2740 })];
2741 }
2742 let anim = BuildAnimationCtx {
2743 time: 0.0,
2744 scenario_time: 0.0,
2745 scene_duration: 1.0,
2746 fps: 30,
2747 };
2748 let scene = [shape];
2749 let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2750 let filters = built.root.children[0]
2751 .css
2752 .filter
2753 .as_ref()
2754 .expect("glow must add a filter list");
2755 let shadow = filters
2756 .iter()
2757 .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2758 .expect("a DropShadow filter must be present");
2759 let FilterFn::DropShadow { blur, color, .. } = shadow else {
2760 unreachable!()
2761 };
2762 match blur {
2763 Some(CLength::Px(px)) => {
2764 assert_eq!(*px, 12.0, "blur radius must match GlowConfig.radius")
2765 }
2766 other => panic!("expected Some(Length::Px(12.0)) blur, got {:?}", other),
2767 }
2768 match color {
2772 Some(Color::Rgba { r, g, b, a }) => {
2773 assert_eq!(*r, 0x5C);
2774 assert_eq!(*g, 0x39);
2775 assert_eq!(*b, 0xEE);
2776 assert!(*a > 0.0, "alpha must be > 0 for the glow to be visible");
2777 }
2778 other => panic!("expected Some(Color::Rgba{{..}}), got {:?}", other),
2779 }
2780 }
2781
2782 #[test]
2783 fn glow_effect_scales_alpha_by_intensity() {
2784 use rustmotion_core::css::style::{Color, FilterFn};
2785 use rustmotion_core::schema::{AnimationEffect, GlowConfig};
2786
2787 let mut shape = make_shape(100.0, 100.0);
2788 if let Component::Shape(ref mut s) = shape.component {
2789 s.style.animation = vec![AnimationEffect::Glow(GlowConfig {
2790 color: "#FFFFFFFF".to_string(), radius: 10.0,
2792 intensity: 0.5,
2793 })];
2794 }
2795 let anim = BuildAnimationCtx {
2796 time: 0.0,
2797 scenario_time: 0.0,
2798 scene_duration: 1.0,
2799 fps: 30,
2800 };
2801 let scene = [shape];
2802 let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2803 let filters = built.root.children[0].css.filter.as_ref().unwrap();
2804 let FilterFn::DropShadow { color, .. } = filters
2805 .iter()
2806 .find(|f| matches!(f, FilterFn::DropShadow { .. }))
2807 .unwrap()
2808 else {
2809 unreachable!()
2810 };
2811 let Some(Color::Rgba { a, .. }) = color else {
2812 panic!("expected Rgba color");
2813 };
2814 assert!(
2815 (*a - 0.5).abs() < 1e-4,
2816 "intensity 0.5 on an opaque colour must scale alpha to ~0.5, got {}",
2817 a
2818 );
2819 }
2820
2821 #[test]
2822 fn no_glow_effect_leaves_filter_untouched() {
2823 let shape = make_shape(100.0, 100.0);
2824 let anim = BuildAnimationCtx {
2825 time: 0.0,
2826 scenario_time: 0.0,
2827 scene_duration: 1.0,
2828 fps: 30,
2829 };
2830 let scene = [shape];
2831 let built = build_scene_with_anim(&scene, (400.0, 400.0), anim);
2832 assert!(built.root.children[0].css.filter.is_none());
2833 }
2834
2835 fn child_from_json(json: serde_json::Value) -> ChildComponent {
2841 let component: Component =
2842 serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{e}\n{json:#}"));
2843 ChildComponent {
2844 component,
2845 position: None,
2846 x: None,
2847 y: None,
2848 z_index: None,
2849 bleed: false,
2850 }
2851 }
2852
2853 fn layout_in_auto_card(child_json: serde_json::Value) -> (f32, f32) {
2859 let card = make_card(vec![child_from_json(child_json)], CssStyle::default());
2860 let scene = vec![ChildComponent {
2861 component: card,
2862 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
2863 x: None,
2864 y: None,
2865 z_index: None,
2866 bleed: false,
2867 }];
2868 let built = build_scene(&scene, (1920.0, 1080.0));
2869 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
2870 let child_id = built.root.children[0].children[0].id;
2871 let l = layout.get(child_id).expect("child laid out");
2872 (l.width, l.height)
2873 }
2874
2875 #[test]
2883 fn all_23_unsized_components_get_a_positive_box_in_a_card() {
2884 let cases: &[(&str, serde_json::Value)] = &[
2885 ("callout", json!({"type":"callout","text":"Hello"})),
2886 (
2887 "chart",
2888 json!({"type":"chart","chart_type":"bar","data":[{"value":10}]}),
2889 ),
2890 ("comparison", json!({"type":"comparison"})),
2891 (
2892 "dot_map",
2893 json!({"type":"dot_map","points":[{"lat":10.0,"lng":10.0}]}),
2894 ),
2895 ("gauge", json!({"type":"gauge","value":50})),
2896 ("gif", json!({"type":"gif","src":"a.gif"})),
2897 ("heatmap", json!({"type":"heatmap","data":[[1.0,2.0]]})),
2898 ("icon", json!({"type":"icon","icon":"lucide:home"})),
2899 ("image", json!({"type":"image","src":"a.png"})),
2900 ("lottie", json!({"type":"lottie","data":"{}"})),
2901 ("marquee", json!({"type":"marquee","content":"hi"})),
2902 (
2903 "mockup",
2904 json!({"type":"mockup","device":"iphone","src":"a.png"}),
2905 ),
2906 ("pill_nav", json!({"type":"pill_nav","items":["A","B"]})),
2907 ("shape", json!({"type":"shape","shape":"rect"})),
2908 ("skeleton", json!({"type":"skeleton"})),
2909 (
2910 "skeleton_text",
2911 json!({"type":"skeleton","variant":"text","lines":3}),
2912 ),
2913 (
2914 "sparkline",
2915 json!({"type":"sparkline","data":[1.0,2.0,3.0]}),
2916 ),
2917 ("stat", json!({"type":"stat","value":"42"})),
2918 (
2919 "stepper",
2920 json!({"type":"stepper","steps":[{"label":"A"},{"label":"B"}]}),
2921 ),
2922 ("svg", json!({"type":"svg","data":"<svg></svg>"})),
2923 (
2924 "tag_cloud",
2925 json!({"type":"tag_cloud","tags":[{"text":"rust","weight":1.0}]}),
2926 ),
2927 ("tooltip", json!({"type":"tooltip","text":"hi"})),
2928 ("treemap", json!({"type":"treemap","data":[{"value":10.0}]})),
2929 ("video", json!({"type":"video","src":"a.mp4"})),
2930 ];
2931 for (name, json) in cases {
2932 let (w, h) = layout_in_auto_card(json.clone());
2933 assert!(w > 0.0, "{name}: width should be > 0, got {w}");
2934 assert!(h > 0.0, "{name}: height should be > 0, got {h}");
2935 }
2936 }
2937
2938 #[test]
2939 fn heatmap_intrinsic_size_matches_cell_grid_formula() {
2940 let (w, h) =
2943 layout_in_auto_card(json!({"type":"heatmap","data":[[1.0,2.0,3.0],[4.0,5.0,6.0]]}));
2944 assert_eq!(w, 48.0);
2945 assert_eq!(h, 31.0);
2946 }
2947
2948 #[test]
2949 fn sparkline_gets_the_documented_120x40_default() {
2950 let (w, h) = layout_in_auto_card(json!({"type":"sparkline","data":[1.0,2.0,3.0]}));
2953 assert_eq!(w, 120.0);
2954 assert_eq!(h, 40.0);
2955 }
2956
2957 #[test]
2958 fn stat_gets_the_documented_280x180_default() {
2959 let (w, h) = layout_in_auto_card(json!({"type":"stat","value":"42"}));
2961 assert_eq!(w, 280.0);
2962 assert_eq!(h, 180.0);
2963 }
2964
2965 #[test]
2966 fn gauge_default_size_is_square() {
2967 let (w, h) = layout_in_auto_card(json!({"type":"gauge","value":50}));
2968 assert_eq!(w, h);
2969 assert!(w > 0.0);
2970 }
2971
2972 #[test]
2973 fn pill_nav_height_promotes_its_own_declared_field() {
2974 let (_, h) = layout_in_auto_card(json!({"type":"pill_nav","items":["Overview"]}));
2977 assert_eq!(h, 44.0);
2978 }
2979
2980 #[test]
2981 fn callout_width_grows_with_its_own_text_content() {
2982 let (short_w, _) = layout_in_auto_card(json!({"type":"callout","text":"Hi"}));
2986 let (long_w, _) = layout_in_auto_card(
2987 json!({"type":"callout","text":"This is a much longer callout message"}),
2988 );
2989 assert!(
2990 long_w > short_w,
2991 "longer text should measure a wider box: short={short_w}, long={long_w}"
2992 );
2993 }
2994
2995 #[test]
2996 fn three_stats_in_a_flex_row_all_get_a_positive_width() {
2997 let stats = vec![
3002 child_from_json(json!({"type":"stat","value":"45.2K","label":"Users"})),
3003 child_from_json(json!({"type":"stat","value":"12%","label":"Growth"})),
3004 child_from_json(json!({"type":"stat","value":"$8.1M","label":"Revenue"})),
3005 ];
3006 let card = make_card(
3007 stats,
3008 CssStyle {
3009 display: Some(Display::Flex),
3010 flex_direction: Some(FlexDirection::Row),
3011 gap: Some(Gap::Uniform(LengthPercentage::Px(16.0))),
3012 ..Default::default()
3013 },
3014 );
3015 let scene = vec![ChildComponent {
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, (1920.0, 1080.0));
3024 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3025 for (i, child) in built.root.children[0].children.iter().enumerate() {
3026 let l = layout.get(child.id).expect("stat laid out");
3027 assert!(
3028 l.width > 0.0,
3029 "stat #{i}: width should be > 0, got {}",
3030 l.width
3031 );
3032 assert!(
3033 l.height > 0.0,
3034 "stat #{i}: height should be > 0, got {}",
3035 l.height
3036 );
3037 }
3038 }
3039
3040 #[test]
3046 fn card_color_cascades_to_text_child_with_no_color_of_its_own() {
3047 use rustmotion_core::css::style::Color;
3048
3049 let card = make_card(
3050 vec![make_text("hello", CssStyle::default())],
3051 CssStyle {
3052 color: Some(Color::String("#ff0000".into())),
3053 ..Default::default()
3054 },
3055 );
3056 let scene = vec![ChildComponent {
3057 component: card,
3058 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3059 x: None,
3060 y: None,
3061 z_index: None,
3062 bleed: false,
3063 }];
3064 let built = build_scene(&scene, (800.0, 600.0));
3065 let text_box = &built.root.children[0].children[0];
3066 assert_eq!(
3067 text_box.css.color,
3068 Some(Color::String("#ff0000".into())),
3069 "text child declares no color of its own — it should inherit the card's"
3070 );
3071 }
3072
3073 #[test]
3074 fn text_own_color_wins_over_inherited_card_color() {
3075 use rustmotion_core::css::style::Color;
3076
3077 let card = make_card(
3078 vec![make_text(
3079 "hello",
3080 CssStyle {
3081 color: Some(Color::String("#00ff00".into())),
3082 ..Default::default()
3083 },
3084 )],
3085 CssStyle {
3086 color: Some(Color::String("#ff0000".into())),
3087 ..Default::default()
3088 },
3089 );
3090 let scene = vec![ChildComponent {
3091 component: card,
3092 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3093 x: None,
3094 y: None,
3095 z_index: None,
3096 bleed: false,
3097 }];
3098 let built = build_scene(&scene, (800.0, 600.0));
3099 let text_box = &built.root.children[0].children[0];
3100 assert_eq!(
3101 text_box.css.color,
3102 Some(Color::String("#00ff00".into())),
3103 "text child's own explicit color must win over the inherited card color"
3104 );
3105 }
3106
3107 #[test]
3108 fn card_display_does_not_cascade_to_text_child() {
3109 let card = make_card(
3113 vec![make_text("hello", CssStyle::default())],
3114 CssStyle {
3115 display: Some(Display::Flex),
3116 ..Default::default()
3117 },
3118 );
3119 let scene = vec![ChildComponent {
3120 component: card,
3121 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3122 x: None,
3123 y: None,
3124 z_index: None,
3125 bleed: false,
3126 }];
3127 let built = build_scene(&scene, (800.0, 600.0));
3128 let text_box = &built.root.children[0].children[0];
3129 assert_eq!(text_box.css.display, None);
3130 }
3131
3132 fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent {
3136 ChildComponent {
3137 component: Component::Shape(crate::shape::Shape {
3138 shape: rustmotion_core::schema::ShapeType::Rect,
3139 text: None,
3140 timing: Default::default(),
3141 style: CssStyle {
3142 width: Some(CSize::Length(CLP::Px(width))),
3143 aspect_ratio: Some(aspect_ratio),
3144 ..Default::default()
3145 },
3146 timeline: Vec::new(),
3147 stagger: None,
3148 fill: None,
3149 stroke: None,
3150 }),
3151 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3152 x: None,
3153 y: None,
3154 z_index: None,
3155 bleed: false,
3156 }
3157 }
3158
3159 #[test]
3160 fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() {
3161 let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)];
3165 let built = build_scene(&scene, (1920.0, 1080.0));
3166 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3167 let l = layout
3168 .get(built.root.children[0].id)
3169 .expect("shape laid out");
3170 assert!(
3171 (l.width - 400.0).abs() < 1.0,
3172 "width should stay the author's explicit 400, got {}",
3173 l.width
3174 );
3175 assert!(
3176 (l.height - 225.0).abs() < 1.0,
3177 "height should derive from width/aspect-ratio (400/1.778=225), got {}",
3178 l.height
3179 );
3180 }
3181
3182 #[test]
3183 fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() {
3184 let scene = vec![make_aspect_shape_no_width(2.0)];
3188 let built = build_scene(&scene, (1920.0, 1080.0));
3189 let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
3190 let l = layout
3191 .get(built.root.children[0].id)
3192 .expect("shape laid out");
3193 assert!(
3194 (l.width - 80.0).abs() < 1.0,
3195 "width should keep the natural default (80), got {}",
3196 l.width
3197 );
3198 assert!(
3199 (l.height - 40.0).abs() < 1.0,
3200 "height should derive from the default width/aspect-ratio (80/2=40), got {}",
3201 l.height
3202 );
3203 }
3204
3205 fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent {
3206 ChildComponent {
3207 component: Component::Shape(crate::shape::Shape {
3208 shape: rustmotion_core::schema::ShapeType::Rect,
3209 text: None,
3210 timing: Default::default(),
3211 style: CssStyle {
3212 aspect_ratio: Some(aspect_ratio),
3213 ..Default::default()
3214 },
3215 timeline: Vec::new(),
3216 stagger: None,
3217 fill: None,
3218 stroke: None,
3219 }),
3220 position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
3221 x: None,
3222 y: None,
3223 z_index: None,
3224 bleed: false,
3225 }
3226 }
3227}