Skip to main content

rustmotion_components/
legacy_dispatch.rs

1//! Paint dispatcher for the new `paint_tree` pipeline. Bridges from the
2//! taffy-laid-out `BoxNode` tree back to component-typed `Painter` impls.
3//!
4//! Naming kept as "legacy" for now to avoid churn in callers; this is the
5//! sole dispatcher in use since all 51 components implement `Painter`.
6//!
7//! Containers (Card / Flex / Grid / Container / Positioned) are intentionally
8//! skipped: paint_pass already paints their box decorations and recurses into
9//! children — so calling the container's own `paint_content` would do nothing
10//! anyway, and we save a no-op call.
11
12use 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
21/// Maps NodeIds to `ChildComponent`s and dispatches paint to their
22/// `Painter::paint_content` impls.
23pub struct LegacyPaintDispatcher<'a> {
24    /// `components[id as usize]` is the component for `id`. Slot 0 is the
25    /// synthetic root and is always `None`.
26    components: &'a [Option<&'a ChildComponent>],
27    /// Per-node container-stagger delay (indexed like `components`); empty
28    /// when the caller doesn't carry stagger information.
29    stagger_delays: &'a [f64],
30    /// Per-node accumulated affine time remap `(scale, shift)` from ancestor
31    /// containers' `time_scale`/`time_offset` (indexed like `components`);
32    /// `t_local = scale * t_global + shift`. Empty → identity everywhere.
33    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    /// Build from a [`BuiltScene`], carrying its stagger delays so internal
46    /// animations shift by the same amount as the CSS overrides, and its
47    /// per-node time remaps so internal animations (counter, draw_in,
48    /// typewriter…) advance at the same local time as the CSS overrides.
49    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        // Containers paint nothing of their own here — children are handled
80        // recursively by paint_tree, and decorations were already painted.
81        if is_container(&child.component) {
82            return;
83        }
84
85        // Resolve animations for this leaf. Outer transforms (translate /
86        // scale / rotate / opacity / blur) are applied by `paint_pass` via
87        // the CSS overrides injected at box-tree build time, so we don't
88        // wrap the canvas here. `props` is still needed for internal-only
89        // fields like `draw_progress`, `stroke_width`, `visible_chars*`,
90        // and `char_animation`. Timeline steps and container-stagger delays
91        // are folded in so those internal animations shift exactly like the
92        // CSS overrides do.
93        let stagger_delay = self
94            .stagger_delays
95            .get(*node_id as usize)
96            .copied()
97            .unwrap_or(0.0);
98        // Ancestor `time_scale`/`time_offset` remap the time seen by this
99        // node's whole animation surface: internal effect resolution below
100        // AND `PaintCtx.time` (a counter or a typewriter inside a slowed
101        // container must advance at local time). `scene_duration` stays
102        // GLOBAL — it describes the physical scene window, not the remapped
103        // timeline, so duration-relative effects keep their real-time span.
104        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        // The `Painter` contract (traits/painter.rs, rules/paint-context.md)
123        // promises the canvas is already translated to the CONTENT-box
124        // origin, with `layout` describing the content box — padding
125        // reserved by taffy is consumed here, not left for the painter to
126        // rediscover. `Codeblock` is a deliberate, documented exception: it
127        // reads `style.padding` itself (`codeblock/render.rs` computes
128        // `code_x = x + pad_left + gutter_width` from the layout origin it
129        // receives) and paints its own background/border directly from the
130        // BORDER-box origin. Honoring the general contract for it too would
131        // double-apply padding — content shifted twice, background rect
132        // shrunk incorrectly — so it keeps receiving the untranslated
133        // border-box origin and dimensions, exactly as before this fix.
134        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        // A 100x80 red shape at (0,0) with `padding: 20`. The Painter
226        // contract (traits/painter.rs, rules/paint-context.md) promises the
227        // canvas is already translated to the CONTENT-box origin — so the
228        // shape's own fill (which just paints (0,0)..(layout.width,
229        // layout.height)) should only cover the 60x40 content box (20,20)
230        // to (80,60), leaving the 20px padding ring showing the (empty/
231        // background) canvas underneath. Bug: the dispatcher translated to
232        // the BORDER-box origin and handed the painter the full border-box
233        // dimensions, so the fill ignored padding entirely and covered the
234        // whole (0,0)-(100,80) box.
235        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        // Inside the padding ring (5,5): must NOT be red after the fix.
286        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        // Deep inside the content box (50,40): must be red either way.
293        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        // Sanity: the only component slot at idx 1 points to the shape.
308        assert!(built.components[0].is_none());
309        assert!(built.components[1].is_some());
310
311        // Build a Skia raster surface and run a paint pass against the
312        // dispatcher — this just exercises the dispatcher hook end-to-end.
313        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        // Read back the pixel at the centre of the shape (10+25, 20+15)=(35,35)
336        // and assert it's red-ish — confirms paint_content painted.
337        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        // Red channel should dominate (#ff0000).
354        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        // Card 100×80 at (40,30), green background, contains a red 30×20 shape
362        // absolutely positioned at (10,10) inside the card.
363        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        // Card background area: bottom-right corner of the card (well away
457        // from the red shape at (10,10)+(30,20)). Card spans x∈[40,140],
458        // y∈[30,110]. Pick (130, 100) — green.
459        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        // Red shape area: shape spans (50,40)→(80,60). Pick centre (65, 50).
464        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        // A red shape with a `FadeIn` preset over 0.5s, sampled at two points:
472        //   t=0.05s — early in the curve, opacity should be near zero
473        //   t=0.5s  — at the end of the curve, opacity should be ~1
474        // The dispatcher must wire animator output into the canvas alpha,
475        // otherwise both samples render fully opaque and the test fails.
476        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            // Build the box tree with an animation context so the FadeIn
511            // preset is resolved into CSS overrides (transform/opacity) on
512            // each box. paint_pass then applies those during painting.
513            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        // Wrong payload type — must not panic.
609        let bogus: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42i64);
610        // Reach into the dispatcher trait method.
611        // (BoxKind::Component is just a marker here; we drive dispatch directly.)
612        dispatcher.dispatch(canvas, bogus.as_ref(), &css, &layout, &frame);
613        let _ = BoxKind::Container; // touch import
614    }
615}