1use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
13use rustmotion_core::engine::box_tree::NodeId;
14use rustmotion_core::engine::layout_pass::BoxLayout;
15use rustmotion_core::engine::paint_pass::{PaintDispatcher, PaintFrame};
16use rustmotion_core::traits::PaintCtx;
17use skia_safe::Canvas;
18
19use crate::{ChildComponent, Component};
20
21pub struct LegacyPaintDispatcher<'a> {
24 components: &'a [Option<&'a ChildComponent>],
27 stagger_delays: &'a [f64],
30 time_params: &'a [(f64, f64)],
34}
35
36impl<'a> LegacyPaintDispatcher<'a> {
37 pub fn new(components: &'a [Option<&'a ChildComponent>]) -> Self {
38 Self {
39 components,
40 stagger_delays: &[],
41 time_params: &[],
42 }
43 }
44
45 pub fn for_scene(built: &'a crate::box_builder::BuiltScene<'a>) -> Self {
50 Self {
51 components: &built.components,
52 stagger_delays: &built.stagger_delays,
53 time_params: &built.time_params,
54 }
55 }
56
57 fn lookup(&self, id: NodeId) -> Option<&'a ChildComponent> {
58 let idx = id as usize;
59 self.components.get(idx).copied().flatten()
60 }
61}
62
63impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
64 fn dispatch(
65 &self,
66 canvas: &Canvas,
67 payload: &(dyn std::any::Any + Send + Sync),
68 _css: &rustmotion_core::css::CssStyle,
69 layout: &BoxLayout,
70 frame: &PaintFrame,
71 ) {
72 let Some(node_id) = payload.downcast_ref::<NodeId>() else {
73 return;
74 };
75 let Some(child) = self.lookup(*node_id) else {
76 return;
77 };
78
79 if is_container(&child.component) {
82 return;
83 }
84
85 let stagger_delay = self
94 .stagger_delays
95 .get(*node_id as usize)
96 .copied()
97 .unwrap_or(0.0);
98 let (t_scale, t_shift) = self
105 .time_params
106 .get(*node_id as usize)
107 .copied()
108 .unwrap_or((1.0, 0.0));
109 let local_time = frame.time * t_scale + t_shift;
110 let props = match crate::box_builder::effective_effects(&child.component, stagger_delay) {
111 Some(effects) => resolve_props_for_effects(&effects, local_time, frame.scene_duration),
112 None => AnimatedProperties::default(),
113 };
114 if props.opacity <= 0.0 {
115 return;
116 }
117
118 let Some(painter) = child.component.as_painter() else {
119 return;
120 };
121
122 let is_self_padding = matches!(child.component, Component::Codeblock(_));
135
136 canvas.save();
137 let local = if is_self_padding {
138 canvas.translate((layout.x, layout.y));
139 BoxLayout {
140 x: 0.0,
141 y: 0.0,
142 width: layout.width,
143 height: layout.height,
144 ..Default::default()
145 }
146 } else {
147 let (cx, cy, cw, ch) = layout.content_box();
148 canvas.translate((cx, cy));
149 BoxLayout {
150 x: 0.0,
151 y: 0.0,
152 width: cw,
153 height: ch,
154 ..Default::default()
155 }
156 };
157
158 let paint_ctx = PaintCtx {
159 time: local_time,
160 scenario_time: frame.scenario_time,
161 scene_duration: frame.scene_duration,
162 frame_index: frame.frame_index,
163 fps: frame.fps,
164 video_width: frame.video_width,
165 video_height: frame.video_height,
166 stagger_offset: stagger_delay,
167 };
168 painter.paint_content(canvas, &local, &props, &paint_ctx);
169
170 canvas.restore();
171 }
172}
173
174fn is_container(c: &Component) -> bool {
175 matches!(
176 c,
177 Component::Card(_)
178 | Component::Flex(_)
179 | Component::Grid(_)
180 | Component::Container(_)
181 | Component::Positioned(_)
182 )
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::box_builder::build_scene;
189 use crate::shape::Shape;
190 use crate::PositionMode;
191 use rustmotion_core::css::style::{CssStyle, Size as CSize};
192 use rustmotion_core::css::taffy_bridge::ConversionContext;
193 use rustmotion_core::css::units::LengthPercentage as CLP;
194 use rustmotion_core::engine::box_tree::BoxKind;
195 use rustmotion_core::engine::layout_pass::run_layout;
196 use rustmotion_core::schema::ShapeType;
197 use std::sync::Arc;
198
199 fn shape_child(w: f32, h: f32, x: f32, y: f32) -> ChildComponent {
200 ChildComponent {
201 component: Component::Shape(Shape {
202 shape: ShapeType::Rect,
203 text: None,
204 timing: Default::default(),
205 style: CssStyle {
206 width: Some(CSize::Length(CLP::Px(w))),
207 height: Some(CSize::Length(CLP::Px(h))),
208 ..Default::default()
209 },
210 timeline: Vec::new(),
211 stagger: None,
212 fill: Some(rustmotion_core::schema::Fill::Solid("#ff0000".into())),
213 stroke: None,
214 }),
215 position: Some(PositionMode::Absolute { x, y }),
216 x: None,
217 y: None,
218 z_index: None,
219 bleed: false,
220 }
221 }
222
223 #[test]
224 fn leaf_painter_content_is_inset_by_padding() {
225 use rustmotion_core::css::style::Edges;
236
237 let mut scene = vec![shape_child(100.0, 80.0, 0.0, 0.0)];
238 if let Component::Shape(s) = &mut scene[0].component {
239 s.style.padding = Some(Edges::Uniform(CLP::Px(20.0)));
240 }
241 let built = build_scene(&scene, (200.0, 200.0));
242 let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default());
243
244 let mut surface =
245 skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface");
246 let canvas = surface.canvas();
247 let dispatcher = LegacyPaintDispatcher::new(&built.components);
248 let frame = PaintFrame {
249 time: 0.0,
250 scenario_time: 0.0,
251 frame_index: 0,
252 fps: 30,
253 video_width: 200,
254 video_height: 200,
255 scene_duration: 1.0,
256 camera: None,
257 };
258 rustmotion_core::engine::paint_pass::paint_tree(
259 canvas,
260 &built.root,
261 &layout,
262 &frame,
263 &dispatcher,
264 );
265
266 let snapshot = surface.image_snapshot();
267 let info = skia_safe::ImageInfo::new(
268 (1, 1),
269 skia_safe::ColorType::RGBA8888,
270 skia_safe::AlphaType::Premul,
271 None,
272 );
273 let read = |x: i32, y: i32| -> [u8; 4] {
274 let mut buf = [0u8; 4];
275 assert!(snapshot.read_pixels(
276 &info,
277 &mut buf,
278 4,
279 skia_safe::IPoint::new(x, y),
280 skia_safe::image::CachingHint::Disallow,
281 ));
282 buf
283 };
284
285 let padding_zone = read(5, 5);
287 assert!(
288 !(padding_zone[0] > 200 && padding_zone[1] < 50 && padding_zone[2] < 50),
289 "padding ring must not be painted by the leaf's own fill, got {:?}",
290 padding_zone
291 );
292 let content_zone = read(50, 40);
294 assert!(
295 content_zone[0] > 200 && content_zone[1] < 50 && content_zone[2] < 50,
296 "content box must still be painted red, got {:?}",
297 content_zone
298 );
299 }
300
301 #[test]
302 fn dispatch_runs_paint_content_on_leaf() {
303 let scene = vec![shape_child(50.0, 30.0, 10.0, 20.0)];
304 let built = build_scene(&scene, (200.0, 200.0));
305 let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default());
306
307 assert!(built.components[0].is_none());
309 assert!(built.components[1].is_some());
310
311 let mut surface =
314 skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface");
315 let canvas = surface.canvas();
316 let dispatcher = LegacyPaintDispatcher::new(&built.components);
317 let frame = PaintFrame {
318 time: 0.0,
319 scenario_time: 0.0,
320 frame_index: 0,
321 fps: 30,
322 video_width: 200,
323 video_height: 200,
324 scene_duration: 1.0,
325 camera: None,
326 };
327 rustmotion_core::engine::paint_pass::paint_tree(
328 canvas,
329 &built.root,
330 &layout,
331 &frame,
332 &dispatcher,
333 );
334
335 let snapshot = surface.image_snapshot();
338 let mut buf = [0u8; 4];
339 let info = skia_safe::ImageInfo::new(
340 (1, 1),
341 skia_safe::ColorType::RGBA8888,
342 skia_safe::AlphaType::Premul,
343 None,
344 );
345 let read_ok = snapshot.read_pixels(
346 &info,
347 &mut buf,
348 4,
349 skia_safe::IPoint::new(35, 35),
350 skia_safe::image::CachingHint::Disallow,
351 );
352 assert!(read_ok, "pixel read should succeed");
353 assert!(buf[0] > 200, "expected red, got rgba {:?}", buf);
355 assert!(buf[1] < 50, "green should be low, got rgba {:?}", buf);
356 assert!(buf[2] < 50, "blue should be low, got rgba {:?}", buf);
357 }
358
359 #[test]
360 fn card_background_painted_with_red_shape_inside() {
361 use crate::card::Card;
364
365 use rustmotion_core::css::style::{Background, Color};
366
367 let red_shape = ChildComponent {
368 component: Component::Shape(Shape {
369 shape: ShapeType::Rect,
370 text: None,
371 timing: Default::default(),
372 style: CssStyle {
373 width: Some(CSize::Length(CLP::Px(30.0))),
374 height: Some(CSize::Length(CLP::Px(20.0))),
375 ..Default::default()
376 },
377 timeline: Vec::new(),
378 stagger: None,
379 fill: Some(rustmotion_core::schema::Fill::Solid("#ff0000".into())),
380 stroke: None,
381 }),
382 position: Some(PositionMode::Absolute { x: 10.0, y: 10.0 }),
383 x: None,
384 y: None,
385 z_index: None,
386 bleed: false,
387 };
388
389 let card = ChildComponent {
390 component: Component::Card(Card {
391 children: vec![red_shape],
392 timing: Default::default(),
393 style: CssStyle {
394 width: Some(CSize::Length(CLP::Px(100.0))),
395 height: Some(CSize::Length(CLP::Px(80.0))),
396 background: Some(Background::Color(Color::String("#00ff00".into()))),
397 ..Default::default()
398 },
399 timeline: Vec::new(),
400 stagger: None,
401 time_scale: None,
402 time_offset: None,
403 }),
404 position: Some(PositionMode::Absolute { x: 40.0, y: 30.0 }),
405 x: None,
406 y: None,
407 z_index: None,
408 bleed: false,
409 };
410
411 let scene = vec![card];
412 let built = build_scene(&scene, (200.0, 200.0));
413 let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default());
414
415 let mut surface =
416 skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface");
417 let canvas = surface.canvas();
418 let dispatcher = LegacyPaintDispatcher::new(&built.components);
419 let frame = PaintFrame {
420 time: 0.0,
421 scenario_time: 0.0,
422 frame_index: 0,
423 fps: 30,
424 video_width: 200,
425 video_height: 200,
426 scene_duration: 1.0,
427 camera: None,
428 };
429 rustmotion_core::engine::paint_pass::paint_tree(
430 canvas,
431 &built.root,
432 &layout,
433 &frame,
434 &dispatcher,
435 );
436
437 let snapshot = surface.image_snapshot();
438 let info = skia_safe::ImageInfo::new(
439 (1, 1),
440 skia_safe::ColorType::RGBA8888,
441 skia_safe::AlphaType::Premul,
442 None,
443 );
444 let read = |x: i32, y: i32| -> [u8; 4] {
445 let mut buf = [0u8; 4];
446 assert!(snapshot.read_pixels(
447 &info,
448 &mut buf,
449 4,
450 skia_safe::IPoint::new(x, y),
451 skia_safe::image::CachingHint::Disallow,
452 ));
453 buf
454 };
455
456 let bg = read(130, 100);
460 assert!(bg[1] > 200, "expected green card bg, got {:?}", bg);
461 assert!(bg[0] < 50, "red should be low at bg, got {:?}", bg);
462
463 let fg = read(65, 50);
465 assert!(fg[0] > 200, "expected red shape, got {:?}", fg);
466 assert!(fg[1] < 50, "green should be low at shape, got {:?}", fg);
467 }
468
469 #[test]
470 fn fade_in_preset_drives_alpha_through_dispatcher() {
471 use crate::shape::Shape;
477 use rustmotion_core::schema::{AnimationEffect, AnimationTiming, ShapeType};
478
479 let make_scene = || {
480 let shape = ChildComponent {
481 component: Component::Shape(Shape {
482 shape: ShapeType::Rect,
483 text: None,
484 timing: Default::default(),
485 style: CssStyle {
486 width: Some(CSize::Length(CLP::Px(100.0))),
487 height: Some(CSize::Length(CLP::Px(100.0))),
488 animation: vec![AnimationEffect::FadeIn(AnimationTiming {
489 duration: 0.5,
490 ..Default::default()
491 })],
492 ..Default::default()
493 },
494 timeline: Vec::new(),
495 stagger: None,
496 fill: Some(rustmotion_core::schema::Fill::Solid("#ff0000".into())),
497 stroke: None,
498 }),
499 position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }),
500 x: None,
501 y: None,
502 z_index: None,
503 bleed: false,
504 };
505 vec![shape]
506 };
507
508 let sample_red_at = |time: f64| -> u8 {
509 let scene = make_scene();
510 let built = crate::box_builder::build_scene_with_anim(
514 &scene,
515 (200.0, 200.0),
516 crate::box_builder::BuildAnimationCtx {
517 time,
518 scenario_time: time,
519 scene_duration: 1.0,
520 fps: 30,
521 },
522 );
523 let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default());
524 let mut surface =
525 skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface");
526 let canvas = surface.canvas();
527 canvas.clear(skia_safe::Color::BLACK);
528 let dispatcher = LegacyPaintDispatcher::new(&built.components);
529 let frame = PaintFrame {
530 time,
531 scenario_time: time,
532 frame_index: 0,
533 fps: 30,
534 video_width: 200,
535 video_height: 200,
536 scene_duration: 1.0,
537 camera: None,
538 };
539 rustmotion_core::engine::paint_pass::paint_tree(
540 canvas,
541 &built.root,
542 &layout,
543 &frame,
544 &dispatcher,
545 );
546 let snap = surface.image_snapshot();
547 let info = skia_safe::ImageInfo::new(
548 (1, 1),
549 skia_safe::ColorType::RGBA8888,
550 skia_safe::AlphaType::Premul,
551 None,
552 );
553 let mut buf = [0u8; 4];
554 assert!(snap.read_pixels(
555 &info,
556 &mut buf,
557 4,
558 skia_safe::IPoint::new(50, 50),
559 skia_safe::image::CachingHint::Disallow,
560 ));
561 buf[0]
562 };
563
564 let early = sample_red_at(0.05);
565 let late = sample_red_at(0.5);
566 assert!(
567 late > early + 50,
568 "FadeIn should produce a clearly higher red at t=0.5 than at t=0.05 \
569 (early={}, late={})",
570 early,
571 late,
572 );
573 assert!(
574 late > 200,
575 "at t=duration the shape should be ~fully opaque red, got {}",
576 late
577 );
578 assert!(
579 early < 150,
580 "at t=0.05 the shape should be mostly transparent, got {}",
581 early
582 );
583 }
584
585 #[test]
586 fn dispatch_skips_unknown_payloads() {
587 let dispatcher = LegacyPaintDispatcher::new(&[]);
588 let mut surface = skia_safe::surfaces::raster_n32_premul((10, 10)).unwrap();
589 let canvas = surface.canvas();
590 let css = rustmotion_core::css::CssStyle::default();
591 let layout = BoxLayout {
592 x: 0.0,
593 y: 0.0,
594 width: 10.0,
595 height: 10.0,
596 ..Default::default()
597 };
598 let frame = PaintFrame {
599 time: 0.0,
600 scenario_time: 0.0,
601 frame_index: 0,
602 fps: 30,
603 video_width: 10,
604 video_height: 10,
605 scene_duration: 1.0,
606 camera: None,
607 };
608 let bogus: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42i64);
610 dispatcher.dispatch(canvas, bogus.as_ref(), &css, &layout, &frame);
613 let _ = BoxKind::Container; }
615}