diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index dee9b4bb..98423ab0 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -636,7 +636,7 @@ fn build_child<'a>( time_remap, &css, ); - let intrinsic = component_intrinsic(&child.component); + let intrinsic = component_intrinsic(&child.component, &css); let principal = BoxNode { id, @@ -1131,12 +1131,22 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani /// Build an [`IntrinsicMeasure`] for components whose box size depends on /// their content (text, codeblock, terminal, etc.). Returns `None` for /// components with explicit dimensions or pure containers. +/// +/// `cascaded_css` is this node's own `CssStyle` after `cascade::inherit_from` +/// has already merged it against the parent, plus every subsequent overlay +/// (timeline states, animation) — the exact same value `LegacyPaintDispatcher` +/// receives at paint time. `Text` uses it so the reserved box always matches +/// what `Text::paint_content` (which reads the same cascade through +/// `with_cascaded_style`) actually draws. fn component_intrinsic( component: &Component, + cascaded_css: &CssStyle, ) -> Option> { use Component::*; match component { - Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))), + Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text( + &t.with_cascaded_style(cascaded_css), + ))), GradientText(t) => Some(Arc::new( crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t), )), diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index da4fdc58..9d7a6c4e 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -8,12 +8,22 @@ //! skipped: paint_pass already paints their box decorations and recurses into //! children — so calling the container's own `paint_content` would do nothing //! anyway, and we save a no-op call. - +//! +//! `dispatch` receives the node's cascaded `CssStyle` (`css` below) from +//! `paint_pass`, but `Painter::paint_content` has no `CssStyle` parameter — +//! its signature is frozen — and every painter reads typography off its own +//! `self.style` instead. `Text` is the one component wired to see the +//! cascade anyway: `Text::with_cascaded_style` rebuilds it with `css` +//! folded into its own `style` field before painting, the same technique +//! `box_builder::component_intrinsic` uses for `TextIntrinsic` so measure +//! and paint agree. + +use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion_core::engine::box_tree::NodeId; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::paint_pass::{PaintDispatcher, PaintFrame}; -use rustmotion_core::traits::PaintCtx; +use rustmotion_core::traits::{PaintCtx, Painter}; use skia_safe::Canvas; use crate::{ChildComponent, Component}; @@ -65,7 +75,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { &self, canvas: &Canvas, payload: &(dyn std::any::Any + Send + Sync), - _css: &rustmotion_core::css::CssStyle, + css: &CssStyle, layout: &BoxLayout, frame: &PaintFrame, ) { @@ -165,7 +175,12 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { video_height: frame.video_height, stagger_offset: stagger_delay, }; - painter.paint_content(canvas, &local, &props, &paint_ctx); + if let Component::Text(t) = &child.component { + t.with_cascaded_style(css) + .paint_content(canvas, &local, &props, &paint_ctx); + } else { + painter.paint_content(canvas, &local, &props, &paint_ctx); + } canvas.restore(); } diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 527a532c..05fbc996 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -371,9 +371,7 @@ mod tests { // probe specifically for near-white text ink rather than any lit // pixel (the header/row backgrounds paint regardless of font-size). let text_ink = buf - .as_chunks::<4>() - .0 - .iter() + .chunks_exact(4) .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200) .count(); assert!( diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 61f317b3..b1fdb0b8 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -22,7 +22,7 @@ use rustmotion_core::schema::{ }; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Text { pub content: String, #[serde(default)] @@ -59,6 +59,24 @@ rustmotion_core::impl_traits!(Text { Styled => style, }); +impl Text { + /// `Painter::paint_content` and `TextIntrinsic::from_text` both read + /// `self.style` directly and have no way to receive the box tree's + /// resolved `CssStyle` — the `Painter` trait's signature is frozen, and + /// `self` is bound to this exact struct. `resolved` is the box-tree + /// node's own `CssStyle` after `cascade::inherit_from` already merged + /// it against the parent at `box_builder` build time, so — mirroring + /// `inherit_from`'s own "child overrides parent" rule — filling this + /// clone's still-unset inheritable fields from it reproduces the same + /// outcome the box tree already computed, just where the painter and + /// the intrinsic measurer can see it. + pub fn with_cascaded_style(&self, resolved: &CssStyle) -> Self { + let mut clone = self.clone(); + rustmotion_core::css::cascade::inherit_from(resolved, &mut clone.style); + clone + } +} + /// Eased progress (0..1) of unit `idx` at `time`, honouring the config's /// deterministic jitter. fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { diff --git a/crates/rustmotion-components/tests/audit_ws_i.rs b/crates/rustmotion-components/tests/audit_ws_i.rs index 1915ed02..d29ff05c 100644 --- a/crates/rustmotion-components/tests/audit_ws_i.rs +++ b/crates/rustmotion-components/tests/audit_ws_i.rs @@ -12,9 +12,11 @@ //! the painter splits the box evenly across columns regardless. use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; +use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_components::{ChildComponent, Component, PositionMode}; use rustmotion_core::css::taffy_bridge::ConversionContext; use rustmotion_core::engine::layout_pass::run_layout; +use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; fn single_child_scene(json: serde_json::Value) -> ChildComponent { let component: Component = serde_json::from_value(json).expect("deserialize component"); @@ -65,3 +67,148 @@ fn marquee_with_relative_font_size_gets_a_positive_height() { l.height ); } + +// ─── ────────────────────────────────────────────────────────────────── + +const RM07_W: i32 = 600; +const RM07_H: i32 = 500; + +struct PaintedScene { + pixels: Vec, + text_layout_height: f32, +} + +fn paint_card_with_text_child(card_json: serde_json::Value) -> PaintedScene { + let child = single_child_scene(card_json); + let children = vec![child]; + + let built = build_scene_with_anim( + &children, + (RM07_W as f32, RM07_H as f32), + BuildAnimationCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + fps: 30, + }, + ); + let layout = run_layout( + &built.root, + (RM07_W as f32, RM07_H as f32), + &ConversionContext::default(), + ); + let text_id = built.root.children[0].children[0].id; + let text_layout_height = layout.get(text_id).expect("text laid out").height; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((RM07_W, RM07_H)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::BLACK); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + time: 0.0, + scenario_time: 0.0, + frame_index: 0, + fps: 30, + video_width: RM07_W as u32, + video_height: RM07_H as u32, + scene_duration: 1.0, + camera: None, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); + + let row_bytes = RM07_W as usize * 4; + let mut pixels = vec![0u8; row_bytes * RM07_H as usize]; + let info = skia_safe::ImageInfo::new( + (RM07_W, RM07_H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + + PaintedScene { + pixels, + text_layout_height, + } +} + +fn count_dominant(pixels: &[u8], dominant: usize, muted: &[usize]) -> usize { + pixels + .chunks_exact(4) + .filter(|p| p[dominant] > 180 && muted.iter().all(|&m| p[m] < 80)) + .count() +} + +#[test] +fn card_color_and_font_size_cascade_to_painted_text_child() { + // `cascade::inherit_from` computes the twelve inheritable properties but + // `LegacyPaintDispatcher::dispatch` binds the resolved `CssStyle` to + // `_css` and drops it, and `component_intrinsic` builds `TextIntrinsic` + // from `&child.component` (the un-cascaded component) — so a card's + // `color`/`font-size` never reached a child `text` with no value of its + // own, at either paint time or measure time. The text rendered at the + // painter's own fallback (48px, #FFFFFF) instead of the card's (200px, + // red). + let scene = paint_card_with_text_child(serde_json::json!({ + "type": "card", + "style": { + "color": "#ff0000", + "font-size": 200, + "width": 500, + "flex-direction": "column" + }, + "children": [ + { "type": "text", "content": "WWWW" } + ] + })); + + assert!( + scene.text_layout_height > 150.0, + "text child with no font-size of its own must be measured at the \ + card's cascaded 200px (~240px line height), not the 48px default \ + (~57px line height) — got layout height {}", + scene.text_layout_height + ); + + let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); + assert!( + red_pixels > 20, + "text child with no color of its own must paint in the card's \ + cascaded red, not the painter's white fallback — found {red_pixels} \ + red-dominant pixels" + ); +} + +#[test] +fn text_own_color_wins_over_cascaded_card_color_at_paint_time() { + // The other half of the contract this fix must not break: a `text` + // that DOES declare its own `color` must keep winning over the parent's, + // not just at the box-tree level (already covered by + // `box_builder.rs::text_own_color_wins_over_inherited_card_color`) but + // in what actually gets painted. + let scene = paint_card_with_text_child(serde_json::json!({ + "type": "card", + "style": { + "color": "#ff0000", + "width": 500, + "flex-direction": "column" + }, + "children": [ + { "type": "text", "content": "WWWW", "style": { "color": "#00ff00" } } + ] + })); + + let green_pixels = count_dominant(&scene.pixels, 1, &[0, 2]); + let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); + assert!( + green_pixels > 20, + "text's own explicit color must still be painted — found {green_pixels} \ + green-dominant pixels" + ); + assert_eq!( + red_pixels, 0, + "text's own explicit color must win over the card's cascaded red — \ + found {red_pixels} red-dominant pixels" + ); +}