diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index 3c0027e..aa5726b 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -60,6 +60,22 @@ fn validate_children( for (j, child) in children.iter().enumerate() { let p = format!("{}.children[{}]", path, j); + // Properties the CSS engine accepts but does not render yet — warn + // instead of staying silent so authors don't rely on a no-op. + let style = child.component.as_styled().style_config(); + if style.overflow_wrap.is_some() { + warnings.push(format!( + "{}: style.overflow-wrap is accepted but not rendered yet (text wraps per style.wrap)", + p + )); + } + if style.text_overflow.is_some() { + warnings.push(format!( + "{}: style.text-overflow is accepted but not rendered yet (no ellipsis clipping)", + p + )); + } + if let Some(timed) = child.component.as_timed() { let (start, end) = timed.timing(); if let (Some(s), Some(e)) = (start, end) { @@ -303,3 +319,32 @@ fn counter_display_len( sign + integer_digits + separator_chars + decimal_chars + prefix_len + suffix_len } + +#[cfg(test)] +mod style_warning_tests { + use super::*; + + #[test] + fn warns_on_accepted_but_unrendered_css_properties() { + // overflow-wrap and text-overflow parse into CssStyle but are not + // rendered yet; validate must say so instead of staying silent. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { "overflow-wrap": "break-word", "text-overflow": "ellipsis" } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert!( + warnings.iter().any(|w| w.contains("overflow-wrap")), + "missing overflow-wrap warning: {warnings:?}" + ); + assert!( + warnings.iter().any(|w| w.contains("text-overflow")), + "missing text-overflow warning: {warnings:?}" + ); + } +} diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index 2ce60ed..aa06f27 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -59,6 +59,7 @@ impl Counter { layout_width: f32, time: f64, scene_duration: f64, + ctx: &PaintCtx, ) -> Result<()> { use rustmotion_core::engine::animator::ease; @@ -166,8 +167,23 @@ impl Counter { let descent = metrics.descent; let y = (line_height + ascent - descent) / 2.0; - // Draw shadow - if let Some(ref shadow) = self.text_shadow { + // Draw shadows — component field wins, else the bridged CSS + // `style.text-shadow` list (reverse order: first shadow on top). + let shadows: Vec = if let Some(s) = &self.text_shadow { + vec![s.clone()] + } else if let Some(list) = &self.style.text_shadow { + let lctx = rustmotion_core::css::units::LengthContext { + viewport_width: ctx.video_width as f32, + viewport_height: ctx.video_height as f32, + parent_size: layout_width.max(0.0), + font_size, + root_font_size: 16.0, + }; + list.iter().map(|s| s.to_schema(&lctx)).collect() + } else { + Vec::new() + }; + for shadow in shadows.iter().rev() { let mut sp = paint_from_hex(&shadow.color); if shadow.blur > 0.01 { if let Some(filter) = skia_safe::image_filters::blur( @@ -231,6 +247,6 @@ impl Painter for Counter { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - let _ = self.paint(canvas, layout.width, ctx.time, ctx.scene_duration); + let _ = self.paint(canvas, layout.width, ctx.time, ctx.scene_duration, ctx); } } diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index a3ded3d..af2bf8e 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -315,6 +315,7 @@ impl Text { layout_width: f32, time: f64, props: &AnimatedProperties, + ctx: &PaintCtx, ) -> Result<()> { let font_size = self.style.font_size_px_or(48.0); let color = self.style.color_str_or("#FFFFFF"); @@ -388,21 +389,40 @@ impl Text { let descent = metrics.descent; let baseline_offset = (line_height_val + ascent - descent) / 2.0; - // Prepare optional shadow and stroke paints - let shadow_paint = self.text_shadow.as_ref().map(|shadow| { - let mut p = paint_from_hex(&shadow.color); - if shadow.blur > 0.01 { - if let Some(filter) = skia_safe::image_filters::blur( - (shadow.blur, shadow.blur), - skia_safe::TileMode::Clamp, - None, - None, - ) { - p.set_image_filter(filter); + // Prepare optional shadow and stroke paints. The component-level + // `text-shadow` field wins; otherwise the CSS `style.text-shadow` + // list is bridged (it used to be parsed and silently dropped). + let shadows: Vec = if let Some(s) = &self.text_shadow { + vec![s.clone()] + } else if let Some(list) = &self.style.text_shadow { + let lctx = rustmotion_core::css::units::LengthContext { + viewport_width: ctx.video_width as f32, + viewport_height: ctx.video_height as f32, + parent_size: layout_width.max(0.0), + font_size, + root_font_size: 16.0, + }; + list.iter().map(|s| s.to_schema(&lctx)).collect() + } else { + Vec::new() + }; + let shadow_paints: Vec<(skia_safe::Paint, f32, f32)> = shadows + .iter() + .map(|shadow| { + let mut p = paint_from_hex(&shadow.color); + if shadow.blur > 0.01 { + if let Some(filter) = skia_safe::image_filters::blur( + (shadow.blur, shadow.blur), + skia_safe::TileMode::Clamp, + None, + None, + ) { + p.set_image_filter(filter); + } } - } - (p, shadow.offset_x, shadow.offset_y) - }); + (p, shadow.offset_x, shadow.offset_y) + }) + .collect(); let stroke_paint = self.stroke.as_ref().map(|stroke| { let mut p = paint_from_hex(&stroke.color); @@ -486,8 +506,8 @@ impl Text { } } - // Draw shadow - if let Some((ref sp, ox, oy)) = shadow_paint { + // Draw shadows — reverse order so the first CSS shadow ends on top. + for (sp, ox, oy) in shadow_paints.iter().rev() { draw_text_with_fallback( canvas, line, @@ -530,6 +550,6 @@ impl Painter for Text { props: &AnimatedProperties, ctx: &PaintCtx, ) { - let _ = self.paint(canvas, layout.width, ctx.time, props); + let _ = self.paint(canvas, layout.width, ctx.time, props, ctx); } } diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 42f294c..d02e3e6 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -643,6 +643,23 @@ fn one_f32() -> f32 { 1.0 } +impl Color { + /// CSS-string form: pass strings through, format rgba as `#rrggbb[aa]`. + pub fn to_css_string(&self) -> String { + match self { + Color::String(s) => s.clone(), + Color::Rgba { r, g, b, a } => { + if *a >= 1.0 { + format!("#{r:02x}{g:02x}{b:02x}") + } else { + let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8; + format!("#{r:02x}{g:02x}{b:02x}{alpha:02x}") + } + } + } + } +} + // ---- Background ---- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -757,6 +774,22 @@ pub struct TextShadow { pub color: Option, } +impl TextShadow { + /// Resolve into the legacy schema shadow consumed by the text painters. + pub fn to_schema(&self, ctx: &crate::css::units::LengthContext) -> crate::schema::TextShadow { + crate::schema::TextShadow { + color: self + .color + .as_ref() + .map(Color::to_css_string) + .unwrap_or_else(|| "#000000".to_string()), + offset_x: self.offset_x.resolve(ctx), + offset_y: self.offset_y.resolve(ctx), + blur: self.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0), + } + } +} + // ---- Transform ---- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index b8e7f26..9c40eeb 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -216,11 +216,22 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { }); } - // 3. opacity layer + // 3. opacity / filter layer — one shared layer carries both the group + // alpha and the CSS `filter` chain (applies to the node and its subtree). let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0); - let opened_opacity_layer = if opacity < 1.0 { + let content_filter = node + .css + .filter + .as_deref() + .and_then(|list| filters_to_image_filter(list, &length_ctx)); + let opened_opacity_layer = if opacity < 1.0 || content_filter.is_some() { let mut paint = Paint::default(); - paint.set_alpha((opacity * 255.0) as u8); + if opacity < 1.0 { + paint.set_alpha((opacity * 255.0) as u8); + } + if let Some(filter) = content_filter { + paint.set_image_filter(filter); + } let rec = SaveLayerRec::default().paint(&paint); canvas.save_layer(&rec); true @@ -254,6 +265,26 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { } } + // 5.5 backdrop-filter: filter what is already painted behind this node, + // clipped to its (rounded) border-box, before its own background goes on + // top — the glassmorphism pattern. + if let Some(filters) = node.css.backdrop_filter.as_deref() { + if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) { + let radius = node + .css + .border_radius + .as_ref() + .map(|r| resolve_border_radius(r, box_layout, &length_ctx)) + .unwrap_or([0.0; 4]); + canvas.save(); + canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true); + let rec = SaveLayerRec::default().backdrop(&backdrop); + canvas.save_layer(&rec); + canvas.restore(); + canvas.restore(); + } + } + // 6. background if let Some(bg) = node.css.background.as_ref() { paint_background(canvas, box_layout, &node.css, bg, &length_ctx); @@ -292,6 +323,148 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { canvas.restore(); } +// ---- CSS filters ---- + +/// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter` +/// list. Color functions use the CSS Filter Effects spec matrices; Skia's +/// `color_filters::matrix_row_major` expects the translation column in +/// normalized 0..1 space (verified by `css_filter_invert_flips_colors`). +fn filters_to_image_filter( + list: &[crate::css::style::FilterFn], + ctx: &LengthContext, +) -> Option { + use crate::css::style::FilterFn; + use skia_safe::image_filters; + + let mut chain: Option = None; + for f in list { + chain = match f { + FilterFn::Blur { radius } => { + let r = radius.resolve(ctx).max(0.0); + if r <= 0.0 { + chain + } else { + image_filters::blur((r / 2.0, r / 2.0), skia_safe::TileMode::Clamp, chain, None) + } + } + FilterFn::DropShadow { + offset_x, + offset_y, + blur, + color, + } => { + let sigma = blur.as_ref().map(|b| b.resolve(ctx) / 2.0).unwrap_or(0.0); + let c = color.as_ref().map(parse_color).unwrap_or(SColor::BLACK); + image_filters::drop_shadow( + (offset_x.resolve(ctx), offset_y.resolve(ctx)), + (sigma, sigma), + c, + None, + chain, + None, + ) + } + other => color_matrix_for(other) + .map(|m| skia_safe::color_filters::matrix_row_major(&m, None)) + .and_then(|cf| image_filters::color_filter(cf, chain, None)), + }; + } + chain +} + +/// 4x5 row-major color matrix for a CSS color filter function (translation +/// column in normalized 0..1 space), or `None` for the non-matrix functions. +fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> { + use crate::css::style::FilterFn; + #[rustfmt::skip] + fn saturation(s: f32) -> [f32; 20] { + // Luminance weights per the CSS Filter Effects spec. + let (r, g, b) = (0.213, 0.715, 0.072); + [ + r + (1.0 - r) * s, g * (1.0 - s), b * (1.0 - s), 0.0, 0.0, + r * (1.0 - s), g + (1.0 - g) * s, b * (1.0 - s), 0.0, 0.0, + r * (1.0 - s), g * (1.0 - s), b + (1.0 - b) * s, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ] + } + match f { + FilterFn::Brightness { value } => { + let v = value.max(0.0); + #[rustfmt::skip] + let m = [ + v, 0.0, 0.0, 0.0, 0.0, + 0.0, v, 0.0, 0.0, 0.0, + 0.0, 0.0, v, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + Some(m) + } + FilterFn::Contrast { value } => { + let v = value.max(0.0); + let t = (1.0 - v) / 2.0; + #[rustfmt::skip] + let m = [ + v, 0.0, 0.0, 0.0, t, + 0.0, v, 0.0, 0.0, t, + 0.0, 0.0, v, 0.0, t, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + Some(m) + } + FilterFn::Saturate { value } => Some(saturation(value.max(0.0))), + FilterFn::Grayscale { value } => Some(saturation(1.0 - value.clamp(0.0, 1.0))), + FilterFn::HueRotate { deg } => { + let (sin, cos) = deg.to_radians().sin_cos(); + let (r, g, b) = (0.213, 0.715, 0.072); + #[rustfmt::skip] + let m = [ + r + cos * (1.0 - r) + sin * (-r), g + cos * (-g) + sin * (-g), b + cos * (-b) + sin * (1.0 - b), 0.0, 0.0, + r + cos * (-r) + sin * 0.143, g + cos * (1.0 - g) + sin * 0.140, b + cos * (-b) + sin * (-0.283), 0.0, 0.0, + r + cos * (-r) + sin * (-(1.0 - r)), g + cos * (-g) + sin * g, b + cos * (1.0 - b) + sin * b, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + Some(m) + } + FilterFn::Invert { value } => { + let v = value.clamp(0.0, 1.0); + let s = 1.0 - 2.0 * v; + let t = v; + #[rustfmt::skip] + let m = [ + s, 0.0, 0.0, 0.0, t, + 0.0, s, 0.0, 0.0, t, + 0.0, 0.0, s, 0.0, t, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + Some(m) + } + FilterFn::Sepia { value } => { + let v = value.clamp(0.0, 1.0); + let lerp = |a: f32, b: f32| a + (b - a) * v; + #[rustfmt::skip] + let m = [ + lerp(1.0, 0.393), lerp(0.0, 0.769), lerp(0.0, 0.189), 0.0, 0.0, + lerp(0.0, 0.349), lerp(1.0, 0.686), lerp(0.0, 0.168), 0.0, 0.0, + lerp(0.0, 0.272), lerp(0.0, 0.534), lerp(1.0, 0.131), 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + Some(m) + } + FilterFn::Opacity { value } => { + let v = value.clamp(0.0, 1.0); + #[rustfmt::skip] + let m = [ + 1.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, v, 0.0, + ]; + Some(m) + } + FilterFn::Blur { .. } | FilterFn::DropShadow { .. } => None, + } +} + // ---- Transform ---- fn has_3d_transform(list: &[TransformFn]) -> bool { @@ -969,6 +1142,103 @@ mod hit_tests { assert!((h.rect.h - 80.0).abs() < 0.5, "h = {}", h.rect.h); } + #[test] + fn backdrop_filter_blurs_content_behind() { + use crate::css::style::{Background, Color as CssColor, FilterFn}; + use crate::css::units::Length; + + // Top half black on a white root; a backdrop-blur panel straddles + // the boundary. Inside the panel the boundary must smear into greys; + // outside it stays a hard black/white edge. + let black_top = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(0.0)), + top: Some(CLP::Px(0.0)), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + background: Some(Background::Color(CssColor::String("#000000".into()))), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let panel = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(50.0)), + top: Some(CLP::Px(50.0)), + width: Some(CSize::Length(CLP::Px(100.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + backdrop_filter: Some(vec![FilterFn::Blur { + radius: Length::Px(10.0), + }]), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(200.0))), + background: Some(Background::Color(CssColor::String("#ffffff".into()))), + ..Default::default() + }, + children: vec![black_top, panel], + intrinsic: None, + source_path: None, + window: None, + }; + root.assign_ids(0); + + let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap(); + paint_tree( + surface.canvas(), + &root, + &layout, + &test_frame(200, 200), + &NoopDispatcher, + ); + + let info = skia_safe::ImageInfo::new( + (200, 200), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 200 * 200 * 4]; + assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0))); + let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32; + + // Outside the panel: hard edge preserved. + assert!(red(10, 97) < 10, "outside/above must stay black"); + assert!(red(10, 103) > 245, "outside/below must stay white"); + // Inside the panel: boundary smeared to intermediate greys. + let above = red(100, 97); + let below = red(100, 103); + assert!( + above > 30, + "backdrop not blurred above boundary (r={above})" + ); + assert!( + below < 225, + "backdrop not blurred below boundary (r={below})" + ); + } + #[test] fn hitmap_reflects_node_transform() { use crate::css::style::TransformFn; diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 1bf735d..01dbe9f 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -593,6 +593,88 @@ mod component_smoke { ); } + #[test] + fn css_text_shadow_paints_through_the_bridge() { + // `text-shadow` set inside `style` (the CSS dialect form) must paint + // like the component-level field: blue text with a red css shadow + // must produce red pixels. Before the bridge, css text_shadow was + // parsed and silently dropped. + let json = serde_json::json!({ + "type": "text", + "content": "SHADOW", + "style": { + "font-size": "60px", + "color": "#0000ff", + "text-shadow": [{ "offset-x": 8, "offset-y": 8, "color": "#ff0000" }] + } + }); + let component: Component = serde_json::from_value(json).expect("deserialize"); + let child = crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 60.0, y: 40.0 }), + x: None, + y: None, + z_index: None, + }; + let buf = render_new_at(&[child], 400, 300, 0.5, 1.0); + assert!( + red_sum(&buf) > 500, + "css text-shadow painted no red pixels (red_sum={})", + red_sum(&buf) + ); + } + + #[test] + fn css_filter_blur_softens_edges() { + // A hard-edged red rect has almost no intermediate red values + // (antialiasing only); `filter: blur(12px)` must smear the edges into + // thousands of intermediate pixels. Before the fix, css `filter` was + // written by the animator (blur_in/blur_out) and static styles but + // consumed nowhere. + let sharp = red_rect_scene(serde_json::json!({})); + let blurred = red_rect_scene(serde_json::json!({ + "style": { + "width": "100px", + "height": "80px", + "filter": [{ "fn": "blur", "radius": 12 }] + } + })); + let count_mid = |buf: &[u8]| { + buf.chunks_exact(4) + .filter(|p| p[0] > 20 && p[0] < 220) + .count() + }; + let sharp_mid = count_mid(&render_new_at(&sharp, 400, 300, 0.5, 1.0)); + let blur_mid = count_mid(&render_new_at(&blurred, 400, 300, 0.5, 1.0)); + assert!( + blur_mid > sharp_mid * 5 && blur_mid > 1000, + "filter: blur did not soften edges (sharp={sharp_mid}, blurred={blur_mid})" + ); + } + + #[test] + fn css_filter_invert_flips_colors() { + // Pure red #ff3366 inverted → #00cc99: red collapses, green rises. + // Locks the color-matrix translation convention (0..255 space) — + // a wrong convention would leave red pixels untouched or black. + let inverted = red_rect_scene(serde_json::json!({ + "style": { + "width": "100px", + "height": "80px", + "filter": [{ "fn": "invert", "value": 1.0 }] + } + })); + let buf = render_new_at(&inverted, 400, 300, 0.5, 1.0); + let flipped = buf + .chunks_exact(4) + .filter(|p| p[0] < 50 && p[1] > 150) + .count(); + assert!( + flipped > 3000, + "invert(1) did not flip the rect's colors (flipped px = {flipped})" + ); + } + #[test] fn timeline_steps_trigger_delayed_animations() { // A timeline step resolves as its animations with `delay += at`: