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