Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions crates/rustmotion-components/src/box_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Arc<dyn rustmotion_core::engine::box_tree::IntrinsicMeasure>> {
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),
)),
Expand Down
23 changes: 19 additions & 4 deletions crates/rustmotion-components/src/legacy_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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();
}
Expand Down
4 changes: 1 addition & 3 deletions crates/rustmotion-components/src/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
20 changes: 19 additions & 1 deletion crates/rustmotion-components/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down
147 changes: 147 additions & 0 deletions crates/rustmotion-components/tests/audit_ws_i.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<u8>,
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"
);
}
Loading