diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 98423ab0..f5ab158b 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -1135,18 +1135,21 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani /// `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. +/// receives at paint time. `Component::with_cascaded_style` folds it into +/// whichever component variants read inherited typography off their own +/// style before this function's match ever sees them, so the reserved box +/// always matches what those components' painters (which fold the same +/// cascade in at paint time) actually draw. fn component_intrinsic( component: &Component, cascaded_css: &CssStyle, ) -> Option> { + let cascaded_component = component.with_cascaded_style(cascaded_css); + let component = cascaded_component.as_ref().unwrap_or(component); + use Component::*; match component { - Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text( - &t.with_cascaded_style(cascaded_css), - ))), + Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))), GradientText(t) => Some(Arc::new( crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t), )), @@ -2147,7 +2150,7 @@ pub fn component_kind(c: &Component) -> &'static str { Particle(_) => "particle", PillNav(_) => "pill_nav", Progress(_) => "progress", - QrCode(_) => "qrcode", + QrCode(_) => "qr_code", NumberWheel(_) => "number_wheel", SuccessCheck(_) => "success_check", Pointer(_) => "pointer", @@ -2169,7 +2172,11 @@ pub fn component_kind(c: &Component) -> &'static str { Flex(_) => "flex", Grid(_) => "grid", Card(_) => "card", - Container(_) => "container", + // The schema tag is `div` (`#[serde(rename = "div", alias = + // "container")]` on the enum in `lib.rs`) — `container` only + // survives as a deserialize alias, so naming it that way here told + // an author to look for a tag their scenario cannot contain. + Container(_) => "div", AudioSpectrum(_) => "audio_spectrum", Waveform(_) => "waveform", } diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 9d7a6c4e..f3fb225f 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -2,7 +2,7 @@ //! taffy-laid-out `BoxNode` tree back to component-typed `Painter` impls. //! //! Naming kept as "legacy" for now to avoid churn in callers; this is the -//! sole dispatcher in use since all 51 components implement `Painter`. +//! sole dispatcher in use since every component implements `Painter`. //! //! Containers (Card / Flex / Grid / Container / Positioned) are intentionally //! skipped: paint_pass already paints their box decorations and recurses into @@ -12,18 +12,18 @@ //! `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. +//! `self.style` instead. `Component::with_cascaded_style` rebuilds the +//! subset of components that read inherited typography off their own style +//! with `css` folded in before painting, the same call +//! `box_builder::component_intrinsic` makes for the intrinsic measurers 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, Painter}; +use rustmotion_core::traits::PaintCtx; use skia_safe::Canvas; use crate::{ChildComponent, Component}; @@ -125,7 +125,9 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { return; } - let Some(painter) = child.component.as_painter() else { + let cascaded_component = child.component.with_cascaded_style(css); + let effective_component = cascaded_component.as_ref().unwrap_or(&child.component); + let Some(painter) = effective_component.as_painter() else { return; }; @@ -175,12 +177,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { video_height: frame.video_height, stagger_offset: stagger_delay, }; - 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); - } + painter.paint_content(canvas, &local, &props, &paint_ctx); canvas.restore(); } diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 2ac0556f..8a9b8615 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -67,7 +67,8 @@ pub mod world_bitmap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use rustmotion_core::traits::{Animatable, Painter, Styled, Timed}; +use rustmotion_core::css::CssStyle; +use rustmotion_core::traits::{Animatable, Painter, Styled, StyledMut, Timed}; pub use arrow::Arrow; pub use audio_spectrum::AudioSpectrum; @@ -615,8 +616,11 @@ impl Component { } } - /// Returns the Painter trait. All 51 components are migrated to the new - /// pipeline; the dispatcher always uses Painter::paint_content. + /// Returns the Painter trait. Every `Component` variant is migrated to + /// the new pipeline; the dispatcher always uses Painter::paint_content. + /// `tests/audit_ws_i.rs::cascaded_components_match_the_verified_set` + /// exercises every variant — not a count here, which would just go + /// stale again the next time a component is added. pub fn as_painter(&self) -> Option<&dyn Painter> { match self { Component::AudioSpectrum(c) => Some(c), @@ -681,4 +685,134 @@ impl Component { Component::Connector(c) => Some(c), } } + + /// `Some(clone)` for the components whose `Painter`/intrinsic measurer + /// read inherited typography (`color`, `font-*`, `text-align`, + /// `white-space`, ...) off their own `style` field with `resolved`'s + /// twelve `cascade::inherit_from` properties folded in; `None` for every + /// other component, since the cascade cannot affect anything they draw. + /// `resolved` is the caller's own `CssStyle` post-cascade (`box_builder` + /// and `LegacyPaintDispatcher` both already have it on hand). + /// + /// The `true` set below was built by reading every component's own + /// `paint`/`paint_content` (and, where one exists, its `*Intrinsic` + /// measurer) for a direct `self.style.color`/`font_family`/`font_size`/ + /// `font_weight`/`font_style` read with no cascade in between — not by + /// guessing from the component's name. Several read only `font-size`/ + /// `font-family` and keep their own dedicated field for text colour + /// (`Kbd::text_color`, `PillNav::text_color`, `Terminal`'s theme) — + /// still members, since those two properties alone are enough for the + /// same defect: a `font-size` set on a card never reaching the child. + /// + /// Exhaustive on purpose, no wildcard arm: adding a new `Component` + /// variant is a compile error here until this match says whether it + /// belongs to that set — the same completeness `as_painter`/ + /// `as_animatable`/`as_timed`/`as_styled` above already enforce for + /// their own questions, extended to this one. + /// `tests/audit_ws_i.rs::cascaded_components_match_the_verified_set` + /// pins the current membership directly, so a variant silently + /// reclassified here still fails a test even though the compiler has + /// nothing to object to. + /// + /// Builds the clone via a `serde_json` round-trip rather than `Clone`: + /// `Component`'s inner types are already `Serialize + Deserialize` (the + /// whole scenario tree is built that way), but not every one of them is + /// `Clone` — `Caption`'s `CaptionWord`/`CaptionStyle` (`rustmotion-core`) + /// are not, and adding it there is outside this crate. The round trip + /// costs more than a field-wise clone would, but only for the + /// components in the `Some` arm, and only once per box-tree build / + /// paint call — the same per-frame cost class `box_builder` already + /// pays elsewhere. + pub fn with_cascaded_style(&self, resolved: &CssStyle) -> Option { + let is_typographic = match self { + Component::Text(_) + | Component::GradientText(_) + | Component::Caption(_) + | Component::RichText(_) + | Component::Badge(_) + | Component::Callout(_) + | Component::Counter(_) + | Component::Divider(_) + | Component::Icon(_) + | Component::Kbd(_) + | Component::List(_) + | Component::Marquee(_) + | Component::Notification(_) + | Component::NumberWheel(_) + | Component::PillNav(_) + | Component::Table(_) + | Component::Terminal(_) + | Component::Tooltip(_) => true, + Component::AudioSpectrum(_) + | Component::Shape(_) + | Component::Image(_) + | Component::Svg(_) + | Component::Video(_) + | Component::Gif(_) + | Component::Cursor(_) + | Component::Codeblock(_) + | Component::Connector(_) + | Component::Avatar(_) + | Component::AvatarGroup(_) + | Component::Arrow(_) + | Component::Chart(_) + | Component::Comparison(_) + | Component::Countdown(_) + | Component::DotMap(_) + | Component::Gauge(_) + | Component::Heatmap(_) + | Component::Line(_) + | Component::Lottie(_) + | Component::Mockup(_) + | Component::Particle(_) + | Component::Progress(_) + | Component::QrCode(_) + | Component::SuccessCheck(_) + | Component::Pointer(_) + | Component::Rating(_) + | Component::Skeleton(_) + | Component::Slider(_) + | Component::Sparkline(_) + | Component::Stat(_) + | Component::Stepper(_) + | Component::Switch(_) + | Component::TagCloud(_) + | Component::Timeline(_) + | Component::Treemap(_) + | Component::Positioned(_) + | Component::Flex(_) + | Component::Grid(_) + | Component::Card(_) + | Component::Container(_) + | Component::Waveform(_) => false, + }; + if !is_typographic { + return None; + } + let value = serde_json::to_value(self).ok()?; + let mut clone: Component = serde_json::from_value(value).ok()?; + let style = match &mut clone { + Component::Text(c) => c.style_config_mut(), + Component::GradientText(c) => c.style_config_mut(), + Component::Caption(c) => c.style_config_mut(), + Component::RichText(c) => c.style_config_mut(), + Component::Badge(c) => c.style_config_mut(), + Component::Callout(c) => c.style_config_mut(), + Component::Counter(c) => c.style_config_mut(), + Component::Divider(c) => c.style_config_mut(), + Component::Icon(c) => c.style_config_mut(), + Component::Kbd(c) => c.style_config_mut(), + Component::List(c) => c.style_config_mut(), + Component::Marquee(c) => c.style_config_mut(), + Component::Notification(c) => c.style_config_mut(), + Component::NumberWheel(c) => c.style_config_mut(), + Component::PillNav(c) => c.style_config_mut(), + Component::Table(c) => c.style_config_mut(), + Component::Terminal(c) => c.style_config_mut(), + Component::Tooltip(c) => c.style_config_mut(), + _ => unreachable!("classified as typographic by the match above"), + }; + rustmotion_core::css::cascade::inherit_from(resolved, style); + Some(clone) + } } diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index b1fdb0b8..61f317b3 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, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Text { pub content: String, #[serde(default)] @@ -59,24 +59,6 @@ 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 bfd5247d..358e2c9c 100644 --- a/crates/rustmotion-components/tests/audit_ws_i.rs +++ b/crates/rustmotion-components/tests/audit_ws_i.rs @@ -74,10 +74,10 @@ const CASCADE_SCENE_H: i32 = 500; struct PaintedScene { pixels: Vec, - text_layout_height: f32, + child_layout_height: f32, } -fn paint_card_with_text_child(card_json: serde_json::Value) -> PaintedScene { +fn paint_card_with_child(card_json: serde_json::Value) -> PaintedScene { let child = single_child_scene(card_json); let children = vec![child]; @@ -96,8 +96,8 @@ fn paint_card_with_text_child(card_json: serde_json::Value) -> PaintedScene { (CASCADE_SCENE_W as f32, CASCADE_SCENE_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 child_id = built.root.children[0].children[0].id; + let child_layout_height = layout.get(child_id).expect("child laid out").height; let mut surface = skia_safe::surfaces::raster_n32_premul((CASCADE_SCENE_W, CASCADE_SCENE_H)) .expect("raster surface"); @@ -128,7 +128,7 @@ fn paint_card_with_text_child(card_json: serde_json::Value) -> PaintedScene { PaintedScene { pixels, - text_layout_height, + child_layout_height, } } @@ -149,7 +149,7 @@ fn card_color_and_font_size_cascade_to_painted_text_child() { // 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!({ + let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { "color": "#ff0000", @@ -163,11 +163,11 @@ fn card_color_and_font_size_cascade_to_painted_text_child() { })); assert!( - scene.text_layout_height > 150.0, + scene.child_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 + scene.child_layout_height ); let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); @@ -186,7 +186,7 @@ fn text_own_color_wins_over_cascaded_card_color_at_paint_time() { // 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!({ + let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { "color": "#ff0000", @@ -212,6 +212,388 @@ fn text_own_color_wins_over_cascaded_card_color_at_paint_time() { ); } +fn any_ink_above_black_below_y(pixels: &[u8], width: i32, y_threshold: i32) -> bool { + pixels.chunks_exact(4).enumerate().any(|(i, p)| { + let y = i as i32 / width; + y > y_threshold && (p[0] > 20 || p[1] > 20 || p[2] > 20) + }) +} + +#[test] +fn card_font_size_cascades_to_measured_and_painted_gradient_text_child() { + // `gradient_text` was orphaned by the original Text-only special case in + // `LegacyPaintDispatcher::dispatch` — nobody owned this file, so the + // cascade fix for `text` never extended to it even though its painter + // and `GradientTextIntrinsic` read `font-size`/`font-family`/etc. off + // `self.style` exactly the same way. `gradient_text` paints its own + // `colors` gradient rather than `style.color`, so `font-size` (which + // affects both the measured box and the ink's vertical extent) is the + // observable half of the cascade here, not color. + let scene = paint_card_with_child(serde_json::json!({ + "type": "card", + "style": { + "font-size": 200, + "width": 500, + "flex-direction": "column" + }, + "children": [ + { "type": "gradient_text", "content": "W" } + ] + })); + + assert!( + scene.child_layout_height > 150.0, + "gradient_text child with no font-size of its own must be measured \ + at the card's cascaded 200px, not the 48px default — got layout \ + height {}", + scene.child_layout_height + ); + assert!( + any_ink_above_black_below_y(&scene.pixels, CASCADE_SCENE_W, 100), + "gradient_text painted at the card's cascaded 200px must have ink \ + reaching well past y=100 — a 48px default line would not" + ); +} + +#[test] +fn card_color_cascades_to_painted_caption_child() { + // Same orphaned-file gap as gradient_text, for `caption`: its inactive + // words paint in `style.color_str_or("#FFFFFF")`, which never saw the + // card's cascaded color either. + let scene = paint_card_with_child(serde_json::json!({ + "type": "card", + "style": { + "color": "#ff0000", + "width": 500, + "flex-direction": "column" + }, + "children": [ + { + "type": "caption", + "words": [{ "text": "HELLO", "start": 10.0, "end": 20.0 }] + } + ] + })); + + let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); + assert!( + red_pixels > 20, + "caption's inactive word (painted at time=0, outside its \ + [10,20) window) must use the card's cascaded red, not the \ + painter's white fallback — found {red_pixels} red-dominant pixels" + ); +} + +#[test] +fn card_color_cascades_to_painted_rich_text_child() { + // Same gap for `rich_text`: `resolve_span_fonts`'s `default_color` + // (used by any span that doesn't set its own `color`) reads + // `style.color_str_or("#FFFFFF")` on the component's own un-cascaded + // style. + let scene = paint_card_with_child(serde_json::json!({ + "type": "card", + "style": { + "color": "#ff0000", + "width": 500, + "flex-direction": "column" + }, + "children": [ + { "type": "rich_text", "spans": [{ "text": "WWWW" }] } + ] + })); + + let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); + assert!( + red_pixels > 20, + "rich_text span 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 cascaded_components_match_the_verified_set() { + // Pins the current membership of `Component::with_cascaded_style`'s + // exhaustive classifier as an executable fact, not just a convention. + // The match itself guarantees every *future* variant gets a decision — + // adding a `Component` variant without extending that match is a + // compile error, the same completeness `as_painter`/`as_animatable`/ + // `as_timed`/`as_styled` already enforce for their own questions. That + // guarantee says nothing about an *existing* variant silently drifting + // (a copy-paste that drops one into the wrong arm, or a "cleanup" that + // moves one back) — this test is what catches that, by covering all + // sixty variants directly rather than a sample. + // + // The eighteen `true` cases (beyond `text`, already fixed by a prior + // pass) were found by reading every `impl Painter for X` in this crate + // for a direct `self.style.color`/`font_family`/`font_size`/ + // `font_weight`/`font_style` read with no cascade in between — not by + // trusting the component's name. `stat` was checked and ruled out: it + // reads only `self.style.background_color_str` (not inheritable) — its + // text colours come from its own dedicated `value_color`/`label_color` + // fields. + use rustmotion_components::Component; + use rustmotion_core::css::style::{Color, CssStyle as CoreCssStyle}; + use rustmotion_core::css::Length; + + let resolved = CoreCssStyle { + color: Some(Color::String("#ff0000".into())), + font_size: Some(Length::Px(40.0)), + ..Default::default() + }; + + let cases: &[(&str, bool, serde_json::Value)] = &[ + ( + "text", + true, + serde_json::json!({"type":"text","content":"x"}), + ), + ( + "gradient_text", + true, + serde_json::json!({"type":"gradient_text","content":"x"}), + ), + ( + "caption", + true, + serde_json::json!({"type":"caption","words":[{"text":"x","start":0.0,"end":0.0}]}), + ), + ( + "rich_text", + true, + serde_json::json!({"type":"rich_text","spans":[{"text":"x"}]}), + ), + ( + "badge", + true, + serde_json::json!({"type":"badge","text":"x"}), + ), + ( + "callout", + true, + serde_json::json!({"type":"callout","text":"x"}), + ), + ( + "counter", + true, + serde_json::json!({"type":"counter","from":0.0,"to":1.0}), + ), + ("divider", true, serde_json::json!({"type":"divider"})), + ( + "icon", + true, + serde_json::json!({"type":"icon","icon":"lucide:home"}), + ), + ("kbd", true, serde_json::json!({"type":"kbd","key":"A"})), + ( + "list", + true, + serde_json::json!({"type":"list","items":[{"text":"x"}]}), + ), + ( + "marquee", + true, + serde_json::json!({"type":"marquee","content":"x"}), + ), + ( + "notification", + true, + serde_json::json!({"type":"notification","title":"x"}), + ), + ( + "number_wheel", + true, + serde_json::json!({"type":"number_wheel","value":"1"}), + ), + ( + "pill_nav", + true, + serde_json::json!({"type":"pill_nav","items":["a"]}), + ), + ( + "table", + true, + serde_json::json!({"type":"table","headers":["A"],"rows":[]}), + ), + ( + "terminal", + true, + serde_json::json!({"type":"terminal","lines":[]}), + ), + ( + "tooltip", + true, + serde_json::json!({"type":"tooltip","text":"x"}), + ), + ( + "audio_spectrum", + false, + serde_json::json!({"type":"audio_spectrum"}), + ), + ( + "shape", + false, + serde_json::json!({"type":"shape","shape":"rect"}), + ), + ( + "image", + false, + serde_json::json!({"type":"image","src":"x.png"}), + ), + ("svg", false, serde_json::json!({"type":"svg"})), + ( + "video", + false, + serde_json::json!({"type":"video","src":"x.mp4"}), + ), + ( + "gif", + false, + serde_json::json!({"type":"gif","src":"x.gif"}), + ), + ("cursor", false, serde_json::json!({"type":"cursor"})), + ( + "codeblock", + false, + serde_json::json!({"type":"codeblock","code":"x"}), + ), + ( + "connector", + false, + serde_json::json!({"type":"connector","from":{"x":0.0,"y":0.0},"to":{"x":1.0,"y":1.0}}), + ), + ( + "avatar", + false, + serde_json::json!({"type":"avatar","src":"x.png"}), + ), + ( + "avatar_group", + false, + serde_json::json!({"type":"avatar_group","avatars":[]}), + ), + ( + "arrow", + false, + serde_json::json!({"type":"arrow","x2":10.0,"y2":10.0}), + ), + ( + "chart", + false, + serde_json::json!({"type":"chart","chart_type":"bar"}), + ), + ( + "comparison", + false, + serde_json::json!({"type":"comparison"}), + ), + ("countdown", false, serde_json::json!({"type":"countdown"})), + ( + "dot_map", + false, + serde_json::json!({"type":"dot_map","points":[]}), + ), + ("gauge", false, serde_json::json!({"type":"gauge"})), + ( + "heatmap", + false, + serde_json::json!({"type":"heatmap","data":[]}), + ), + ( + "line", + false, + serde_json::json!({"type":"line","x2":10.0,"y2":10.0}), + ), + ("lottie", false, serde_json::json!({"type":"lottie"})), + ( + "mockup", + false, + serde_json::json!({"type":"mockup","device":"iphone","src":"x.png"}), + ), + ( + "particle", + false, + serde_json::json!({"type":"particle","particle_type":"confetti"}), + ), + ("progress", false, serde_json::json!({"type":"progress"})), + ( + "qr_code", + false, + serde_json::json!({"type":"qr_code","content":"x"}), + ), + ( + "success_check", + false, + serde_json::json!({"type":"success_check"}), + ), + ("pointer", false, serde_json::json!({"type":"pointer"})), + ("rating", false, serde_json::json!({"type":"rating"})), + ("skeleton", false, serde_json::json!({"type":"skeleton"})), + ("slider", false, serde_json::json!({"type":"slider"})), + ( + "sparkline", + false, + serde_json::json!({"type":"sparkline","data":[]}), + ), + ( + "stat", + false, + serde_json::json!({"type":"stat","value":"1"}), + ), + ( + "stepper", + false, + serde_json::json!({"type":"stepper","steps":[]}), + ), + ("switch", false, serde_json::json!({"type":"switch"})), + ( + "tag_cloud", + false, + serde_json::json!({"type":"tag_cloud","tags":[]}), + ), + ( + "timeline", + false, + serde_json::json!({"type":"timeline","steps":[]}), + ), + ( + "treemap", + false, + serde_json::json!({"type":"treemap","data":[]}), + ), + ( + "positioned", + false, + serde_json::json!({"type":"positioned"}), + ), + ("flex", false, serde_json::json!({"type":"flex"})), + ("grid", false, serde_json::json!({"type":"grid"})), + ("card", false, serde_json::json!({"type":"card"})), + ("div", false, serde_json::json!({"type":"div"})), + ("waveform", false, serde_json::json!({"type":"waveform"})), + ]; + + assert_eq!( + cases.len(), + 60, + "this table must cover every Component variant (currently 60) — the \ + compiler enforces that with_cascaded_style itself classifies every \ + variant, but only this list enforces that the classification stays \ + the one this workstream verified" + ); + + for (name, expected_typographic, json) in cases { + let component: Component = + serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = component.with_cascaded_style(&resolved).is_some(); + assert_eq!( + got, *expected_typographic, + "{name}: with_cascaded_style returned is_some()={got}, expected \ + {expected_typographic}" + ); + } +} + // ─── table column widths: painter vs. intrinsic measurer ────────────────── #[test] @@ -301,3 +683,207 @@ fn table_columns_are_sized_by_content_not_split_evenly() { found it at x={boundary_x}" ); } + +// ─── the cascade reaches every typographic component, not just `text` ───── +// +// `card_font_size_cascades_to_measured_and_painted_gradient_text_child`, +// `card_color_cascades_to_painted_caption_child` and +// `card_color_cascades_to_painted_rich_text_child` above cover the three +// components the brief named directly. The tests below cover the "own value +// still wins" half for `caption`, plus every other component this sweep +// found reading un-cascaded typography off its own `style`. + +#[test] +fn caption_own_color_wins_over_cascaded_card_color_at_paint_time() { + let scene = paint_card_with_child(serde_json::json!({ + "type": "card", + "style": { "color": "#ff0000", "width": 500, "flex-direction": "column" }, + "children": [ + { + "type": "caption", + "words": [{ "text": "WWWW", "start": 0.0, "end": 0.0 }], + "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, + "caption's own explicit color must still be painted — found \ + {green_pixels} green-dominant pixels" + ); + assert_eq!( + red_pixels, 0, + "caption's own explicit color must win over the card's cascaded red \ + — found {red_pixels} red-dominant pixels" + ); +} + +// ─── the fourteen other components that read `self.style` typography ────── +// +// Found by sweeping every `impl Painter for X` in this crate for a direct +// `self.style.color`/`font_family`/`font_size`/`font_weight`/`font_style` +// read with no cascade in between (the same shape as the three tested +// above), not by re-checking only the candidates the brief named. `stat` +// was checked and ruled out: it reads only `self.style.background_color_str` +// (not inheritable) — its text colours come from its own dedicated +// `value_color`/`label_color` fields. + +#[test] +fn newly_cascaded_components_inherit_unset_typography_from_the_parent() { + use rustmotion_components::Component; + use rustmotion_core::css::style::{Color, CssStyle as CoreCssStyle}; + use rustmotion_core::css::Length; + + let parent = CoreCssStyle { + color: Some(Color::String("#ff0000".into())), + font_size: Some(Length::Px(200.0)), + ..Default::default() + }; + + let cases: &[(&str, serde_json::Value)] = &[ + ("badge", serde_json::json!({"type":"badge","text":"x"})), + ("callout", serde_json::json!({"type":"callout","text":"x"})), + ( + "counter", + serde_json::json!({"type":"counter","from":0.0,"to":1.0}), + ), + ("divider", serde_json::json!({"type":"divider"})), + ( + "icon", + serde_json::json!({"type":"icon","icon":"lucide:home"}), + ), + ("kbd", serde_json::json!({"type":"kbd","key":"A"})), + ( + "list", + serde_json::json!({"type":"list","items":[{"text":"x"}]}), + ), + ( + "marquee", + serde_json::json!({"type":"marquee","content":"x"}), + ), + ( + "notification", + serde_json::json!({"type":"notification","title":"x"}), + ), + ( + "number_wheel", + serde_json::json!({"type":"number_wheel","value":"1"}), + ), + ( + "pill_nav", + serde_json::json!({"type":"pill_nav","items":["a"]}), + ), + ( + "table", + serde_json::json!({"type":"table","headers":["A"],"rows":[]}), + ), + ( + "terminal", + serde_json::json!({"type":"terminal","lines":[]}), + ), + ("tooltip", serde_json::json!({"type":"tooltip","text":"x"})), + ]; + + for (name, json) in cases { + let component: Component = + serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{name}: {e}")); + let cascaded = component + .with_cascaded_style(&parent) + .unwrap_or_else(|| panic!("{name} must be classified as typographic")); + let style = cascaded.as_styled().style_config(); + assert_eq!( + style.color, parent.color, + "{name} with no color of its own must inherit the parent's" + ); + assert_eq!( + style.font_size, parent.font_size, + "{name} with no font-size of its own must inherit the parent's" + ); + } +} + +#[test] +fn table_own_color_wins_over_cascaded_card_color() { + use rustmotion_components::Component; + use rustmotion_core::css::style::{Color, CssStyle as CoreCssStyle}; + + let parent = CoreCssStyle { + color: Some(Color::String("#ff0000".into())), + ..Default::default() + }; + let component: Component = serde_json::from_value(serde_json::json!({ + "type": "table", + "headers": ["A"], + "rows": [], + "style": { "color": "#00ff00" } + })) + .expect("deserialize table"); + let cascaded = component + .with_cascaded_style(&parent) + .expect("table is typographic"); + assert_eq!( + cascaded.as_styled().style_config().color, + Some(Color::String("#00ff00".into())), + "table's own explicit color must win over the cascaded parent's" + ); +} + +#[test] +fn divider_color_cascades_from_card_to_painted_line() { + // `Divider::paint_content` reads `self.style.color_str_or("#FFFFFF")` + // directly for the line it draws — the same un-cascaded read as the + // text-bearing components above, just for a component with no text at + // all. A card's `color` is the CSS analogue of `border-color: + // currentColor`: a divider with no colour of its own should match the + // ambient text colour, not fall back to white. + let scene = paint_card_with_child(serde_json::json!({ + "type": "card", + "style": { + "color": "#ff0000", + "width": 500, + "height": 200, + "flex-direction": "column" + }, + "children": [ { "type": "divider" } ] + })); + + let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); + assert!( + red_pixels > 0, + "divider with no color of its own must paint its line in the card's \ + cascaded red, not the painter's white fallback — found \ + {red_pixels} red-dominant pixels" + ); +} + +#[test] +fn table_color_and_font_size_cascade_to_painted_cells() { + // Complements `table_columns_are_sized_by_content_not_split_evenly` + // above (painter vs. intrinsic column widths) with the cascade half: + // `Table::paint`'s body-cell colour (`self.style.color_str_or`) and + // font-size both used to ignore the card's cascaded style. The header + // row keeps its own hardcoded `header_text_color` default regardless, + // so this only proves the body cells. + let scene = paint_card_with_child(serde_json::json!({ + "type": "card", + "style": { + "color": "#ff0000", + "font-size": 40, + "width": 500, + "flex-direction": "column" + }, + "children": [ + { "type": "table", "headers": ["ID"], "rows": [["1"]] } + ] + })); + + let red_pixels = count_dominant(&scene.pixels, 0, &[1, 2]); + assert!( + red_pixels > 20, + "a table body cell with no color of its own must paint in the \ + card's cascaded red — found {red_pixels} red-dominant pixels" + ); +}