Skip to content

Commit 107e781

Browse files
authored
fix(css): implement filter, backdrop-filter and text-shadow; warn on unrendered props (#29)
Closes #12. CSS filter chain (10 functions) on the group layer, backdrop-filter via clipped backdrop save-layer, css text-shadow bridged into Text/Counter, validator warnings for overflow-wrap/text-overflow. Five pixel-level regression tests.
1 parent cafa619 commit 107e781

6 files changed

Lines changed: 489 additions & 23 deletions

File tree

crates/rustmotion-cli/src/commands/validate_schema.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ fn validate_children(
6060
for (j, child) in children.iter().enumerate() {
6161
let p = format!("{}.children[{}]", path, j);
6262

63+
// Properties the CSS engine accepts but does not render yet — warn
64+
// instead of staying silent so authors don't rely on a no-op.
65+
let style = child.component.as_styled().style_config();
66+
if style.overflow_wrap.is_some() {
67+
warnings.push(format!(
68+
"{}: style.overflow-wrap is accepted but not rendered yet (text wraps per style.wrap)",
69+
p
70+
));
71+
}
72+
if style.text_overflow.is_some() {
73+
warnings.push(format!(
74+
"{}: style.text-overflow is accepted but not rendered yet (no ellipsis clipping)",
75+
p
76+
));
77+
}
78+
6379
if let Some(timed) = child.component.as_timed() {
6480
let (start, end) = timed.timing();
6581
if let (Some(s), Some(e)) = (start, end) {
@@ -303,3 +319,32 @@ fn counter_display_len(
303319

304320
sign + integer_digits + separator_chars + decimal_chars + prefix_len + suffix_len
305321
}
322+
323+
#[cfg(test)]
324+
mod style_warning_tests {
325+
use super::*;
326+
327+
#[test]
328+
fn warns_on_accepted_but_unrendered_css_properties() {
329+
// overflow-wrap and text-overflow parse into CssStyle but are not
330+
// rendered yet; validate must say so instead of staying silent.
331+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
332+
"type": "text",
333+
"content": "hi",
334+
"style": { "overflow-wrap": "break-word", "text-overflow": "ellipsis" }
335+
}))
336+
.unwrap();
337+
let mut errors = Vec::new();
338+
let mut warnings = Vec::new();
339+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
340+
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
341+
assert!(
342+
warnings.iter().any(|w| w.contains("overflow-wrap")),
343+
"missing overflow-wrap warning: {warnings:?}"
344+
);
345+
assert!(
346+
warnings.iter().any(|w| w.contains("text-overflow")),
347+
"missing text-overflow warning: {warnings:?}"
348+
);
349+
}
350+
}

crates/rustmotion-components/src/counter.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ impl Counter {
5959
layout_width: f32,
6060
time: f64,
6161
scene_duration: f64,
62+
ctx: &PaintCtx,
6263
) -> Result<()> {
6364
use rustmotion_core::engine::animator::ease;
6465

@@ -166,8 +167,23 @@ impl Counter {
166167
let descent = metrics.descent;
167168
let y = (line_height + ascent - descent) / 2.0;
168169

169-
// Draw shadow
170-
if let Some(ref shadow) = self.text_shadow {
170+
// Draw shadows — component field wins, else the bridged CSS
171+
// `style.text-shadow` list (reverse order: first shadow on top).
172+
let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
173+
vec![s.clone()]
174+
} else if let Some(list) = &self.style.text_shadow {
175+
let lctx = rustmotion_core::css::units::LengthContext {
176+
viewport_width: ctx.video_width as f32,
177+
viewport_height: ctx.video_height as f32,
178+
parent_size: layout_width.max(0.0),
179+
font_size,
180+
root_font_size: 16.0,
181+
};
182+
list.iter().map(|s| s.to_schema(&lctx)).collect()
183+
} else {
184+
Vec::new()
185+
};
186+
for shadow in shadows.iter().rev() {
171187
let mut sp = paint_from_hex(&shadow.color);
172188
if shadow.blur > 0.01 {
173189
if let Some(filter) = skia_safe::image_filters::blur(
@@ -231,6 +247,6 @@ impl Painter for Counter {
231247
_props: &AnimatedProperties,
232248
ctx: &PaintCtx,
233249
) {
234-
let _ = self.paint(canvas, layout.width, ctx.time, ctx.scene_duration);
250+
let _ = self.paint(canvas, layout.width, ctx.time, ctx.scene_duration, ctx);
235251
}
236252
}

crates/rustmotion-components/src/text.rs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ impl Text {
315315
layout_width: f32,
316316
time: f64,
317317
props: &AnimatedProperties,
318+
ctx: &PaintCtx,
318319
) -> Result<()> {
319320
let font_size = self.style.font_size_px_or(48.0);
320321
let color = self.style.color_str_or("#FFFFFF");
@@ -388,21 +389,40 @@ impl Text {
388389
let descent = metrics.descent;
389390
let baseline_offset = (line_height_val + ascent - descent) / 2.0;
390391

391-
// Prepare optional shadow and stroke paints
392-
let shadow_paint = self.text_shadow.as_ref().map(|shadow| {
393-
let mut p = paint_from_hex(&shadow.color);
394-
if shadow.blur > 0.01 {
395-
if let Some(filter) = skia_safe::image_filters::blur(
396-
(shadow.blur, shadow.blur),
397-
skia_safe::TileMode::Clamp,
398-
None,
399-
None,
400-
) {
401-
p.set_image_filter(filter);
392+
// Prepare optional shadow and stroke paints. The component-level
393+
// `text-shadow` field wins; otherwise the CSS `style.text-shadow`
394+
// list is bridged (it used to be parsed and silently dropped).
395+
let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
396+
vec![s.clone()]
397+
} else if let Some(list) = &self.style.text_shadow {
398+
let lctx = rustmotion_core::css::units::LengthContext {
399+
viewport_width: ctx.video_width as f32,
400+
viewport_height: ctx.video_height as f32,
401+
parent_size: layout_width.max(0.0),
402+
font_size,
403+
root_font_size: 16.0,
404+
};
405+
list.iter().map(|s| s.to_schema(&lctx)).collect()
406+
} else {
407+
Vec::new()
408+
};
409+
let shadow_paints: Vec<(skia_safe::Paint, f32, f32)> = shadows
410+
.iter()
411+
.map(|shadow| {
412+
let mut p = paint_from_hex(&shadow.color);
413+
if shadow.blur > 0.01 {
414+
if let Some(filter) = skia_safe::image_filters::blur(
415+
(shadow.blur, shadow.blur),
416+
skia_safe::TileMode::Clamp,
417+
None,
418+
None,
419+
) {
420+
p.set_image_filter(filter);
421+
}
402422
}
403-
}
404-
(p, shadow.offset_x, shadow.offset_y)
405-
});
423+
(p, shadow.offset_x, shadow.offset_y)
424+
})
425+
.collect();
406426

407427
let stroke_paint = self.stroke.as_ref().map(|stroke| {
408428
let mut p = paint_from_hex(&stroke.color);
@@ -486,8 +506,8 @@ impl Text {
486506
}
487507
}
488508

489-
// Draw shadow
490-
if let Some((ref sp, ox, oy)) = shadow_paint {
509+
// Draw shadows — reverse order so the first CSS shadow ends on top.
510+
for (sp, ox, oy) in shadow_paints.iter().rev() {
491511
draw_text_with_fallback(
492512
canvas,
493513
line,
@@ -530,6 +550,6 @@ impl Painter for Text {
530550
props: &AnimatedProperties,
531551
ctx: &PaintCtx,
532552
) {
533-
let _ = self.paint(canvas, layout.width, ctx.time, props);
553+
let _ = self.paint(canvas, layout.width, ctx.time, props, ctx);
534554
}
535555
}

crates/rustmotion-core/src/css/style.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,23 @@ fn one_f32() -> f32 {
643643
1.0
644644
}
645645

646+
impl Color {
647+
/// CSS-string form: pass strings through, format rgba as `#rrggbb[aa]`.
648+
pub fn to_css_string(&self) -> String {
649+
match self {
650+
Color::String(s) => s.clone(),
651+
Color::Rgba { r, g, b, a } => {
652+
if *a >= 1.0 {
653+
format!("#{r:02x}{g:02x}{b:02x}")
654+
} else {
655+
let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
656+
format!("#{r:02x}{g:02x}{b:02x}{alpha:02x}")
657+
}
658+
}
659+
}
660+
}
661+
}
662+
646663
// ---- Background ----
647664

648665
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -757,6 +774,22 @@ pub struct TextShadow {
757774
pub color: Option<Color>,
758775
}
759776

777+
impl TextShadow {
778+
/// Resolve into the legacy schema shadow consumed by the text painters.
779+
pub fn to_schema(&self, ctx: &crate::css::units::LengthContext) -> crate::schema::TextShadow {
780+
crate::schema::TextShadow {
781+
color: self
782+
.color
783+
.as_ref()
784+
.map(Color::to_css_string)
785+
.unwrap_or_else(|| "#000000".to_string()),
786+
offset_x: self.offset_x.resolve(ctx),
787+
offset_y: self.offset_y.resolve(ctx),
788+
blur: self.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0),
789+
}
790+
}
791+
}
792+
760793
// ---- Transform ----
761794

762795
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]

0 commit comments

Comments
 (0)