From 91a580f1ab0e9743668f7234145ff408cd3c9cce Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 16 Jul 2026 18:17:45 +0800 Subject: [PATCH] Implement CSS box-shadow painting (isu issue 381, parent isu issue 280) --- .isu/issues.json | 2 +- crates/layout/src/lib.rs | 11 ++- crates/render/src/lib.rs | 148 +++++++++++++++++++++++++++- crates/style/src/computed.rs | 186 ++++++++++++++++++++++++++++++++++- 4 files changed, 339 insertions(+), 8 deletions(-) diff --git a/.isu/issues.json b/.isu/issues.json index 54ef7ed..c92046d 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -4671,7 +4671,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-07-16T09:55:15Z" } ] diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index b4109a9..7a37147 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -10,10 +10,10 @@ use we_css::values::{Color, LinearGradient}; use we_dom::{Document, NodeData, NodeId}; use we_style::computed::{ AlignContent, AlignItems, AlignSelf, Appearance, BackgroundRepeat, BackgroundSize, - BorderCollapse, BorderStyle, BoxSizing, Clear, ComputedStyle, Cursor, Display, FlexDirection, - FlexWrap, Float, FontStyle, GridAutoFlow, GridPlacement, GridTrackSize, JustifyContent, - JustifyItems, JustifySelf, LengthOrAuto, Overflow, Position, StyledNode, TextAlign, - TextDecoration, TextOverflow, Transform, Visibility, WhiteSpace, WillChange, + BorderCollapse, BorderStyle, BoxShadow, BoxSizing, Clear, ComputedStyle, Cursor, Display, + FlexDirection, FlexWrap, Float, FontStyle, GridAutoFlow, GridPlacement, GridTrackSize, + JustifyContent, JustifyItems, JustifySelf, LengthOrAuto, Overflow, Position, StyledNode, + TextAlign, TextDecoration, TextOverflow, Transform, Visibility, WhiteSpace, WillChange, }; use we_text::font::Font; @@ -193,6 +193,8 @@ pub struct LayoutBox { pub background_position_x: LengthOrAuto, pub background_position_y: LengthOrAuto, pub background_repeat: BackgroundRepeat, + /// CSS `box-shadow` layers. + pub box_shadows: Vec, /// Text decoration (underline, etc.). pub text_decoration: TextDecoration, /// Border styles (top, right, bottom, left). @@ -335,6 +337,7 @@ impl LayoutBox { background_position_x: style.background_position_x, background_position_y: style.background_position_y, background_repeat: style.background_repeat, + box_shadows: style.box_shadows.clone(), text_decoration: style.text_decoration, border_styles: [ style.border_top_style, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dd77c4c..2dda5e7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -22,8 +22,8 @@ use we_layout::{ SCROLLBAR_WIDTH, }; use we_style::computed::{ - Appearance, BackgroundRepeat, BackgroundSize, BorderStyle, Display, LengthOrAuto, Overflow, - Position, TextDecoration, Visibility, WillChange, + Appearance, BackgroundRepeat, BackgroundSize, BorderStyle, BoxShadow, Display, LengthOrAuto, + Overflow, Position, TextDecoration, Visibility, WillChange, }; /// Scroll state: maps NodeId of scrollable boxes to their (scroll_x, scroll_y) offsets. @@ -333,6 +333,7 @@ fn paint_box( if visible { if let Some(ref fc) = layout_box.form_control { // Form controls paint their own background, borders, and content. + paint_box_shadows(layout_box, list, tx, ty); paint_form_control(layout_box, fc, list, tx, ty); // Collect open dropdown overlays for painting after the main pass. if fc.control_type == FormControlType::Select && fc.dropdown_open { @@ -361,6 +362,7 @@ fn paint_box( ) && layout_box.replaced_size.is_none() && layout_box.display != Display::InlineBlock; if !inline_non_replaced { + paint_box_shadows(layout_box, list, tx, ty); paint_background(layout_box, list, tx, ty); paint_borders(layout_box, list, tx, ty); } @@ -794,6 +796,91 @@ fn stroke_rounded_rect( }); } +fn paint_box_shadows(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { + if layout_box.box_shadows.is_empty() { + return; + } + let bb = border_box(layout_box); + if bb.width <= 0.0 || bb.height <= 0.0 { + return; + } + for shadow in &layout_box.box_shadows { + paint_box_shadow(layout_box, shadow, bb, list, (tx, ty)); + } +} + +fn paint_box_shadow( + layout_box: &LayoutBox, + shadow: &BoxShadow, + border_box: Rect, + list: &mut DisplayList, + translate: (f32, f32), +) { + if shadow.color.a == 0 { + return; + } + + let blur = shadow.blur_radius.max(0.0); + if blur <= 0.5 { + paint_shadow_layer(layout_box, shadow, border_box, list, translate, 0.0, 1.0); + return; + } + + let layers = ((blur / 2.0).ceil() as usize).clamp(3, 12); + let weight_sum = (layers * (layers + 1) / 2) as f32; + for i in 0..layers { + let progress = if layers == 1 { + 1.0 + } else { + i as f32 / (layers - 1) as f32 + }; + let expansion = blur * (1.0 - progress); + let alpha_factor = (i + 1) as f32 / weight_sum; + paint_shadow_layer( + layout_box, + shadow, + border_box, + list, + translate, + expansion, + alpha_factor, + ); + } +} + +fn paint_shadow_layer( + layout_box: &LayoutBox, + shadow: &BoxShadow, + border_box: Rect, + list: &mut DisplayList, + translate: (f32, f32), + expansion: f32, + alpha_factor: f32, +) { + let outset = shadow.spread_radius + expansion; + let x = border_box.x + translate.0 + shadow.offset_x - outset; + let y = border_box.y + translate.1 + shadow.offset_y - outset; + let w = border_box.width + outset * 2.0; + let h = border_box.height + outset * 2.0; + if w <= 0.0 || h <= 0.0 { + return; + } + + let mut color = shadow.color; + color.a = ((color.a as f32 * alpha_factor).round()).clamp(0.0, 255.0) as u8; + if color.a == 0 { + return; + } + + let radii = [ + layout_box.border_radii[0] + outset, + layout_box.border_radii[1] + outset, + layout_box.border_radii[2] + outset, + layout_box.border_radii[3] + outset, + ]; + fill_rounded_rect(list, x, y, w, h, radii, color); +} + fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; // CSS default `background-clip: border-box`: the background fills the @@ -2714,6 +2801,63 @@ body { margin: 0; } assert_eq!(gradient.2.stops[1].color, Color::new(0, 0, 0, 0)); } + #[test] + fn css_box_shadow_paints_before_rounded_background() { + let html_str = r#" + + +
+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let tree = we_layout::layout( + &styled, + &doc, + 800.0, + 600.0, + &font, + &std::collections::HashMap::new(), + ); + + let list = build_display_list(&tree); + let shadow_index = list + .iter() + .position(|cmd| { + matches!( + cmd, + PaintCommand::RoundedRect { color, .. } + if color.r == 0 && color.g == 0 && color.b == 0 && color.a > 0 + ) + }) + .expect("box-shadow should emit rounded shadow paint"); + let background_index = list + .iter() + .position(|cmd| { + matches!( + cmd, + PaintCommand::RoundedRect { color, .. } + if *color == Color::rgb(255, 255, 255) + ) + }) + .expect("rounded background should paint"); + + assert!( + shadow_index < background_index, + "outer shadow must paint behind the element background" + ); + } + #[test] fn hsl_flex_descendant_backgrounds_render_at_flex_positions() { let html_str = r#" diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 9b31f76..61a44f8 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -720,6 +720,20 @@ pub enum BackgroundSize { Explicit(LengthOrAuto, LengthOrAuto), } +/// One computed CSS `box-shadow` layer. +/// +/// Lengths are resolved to CSS px at computed-style time. Inset shadows are +/// intentionally omitted by the parser for now; the renderer supports only +/// ordinary outer shadows. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BoxShadow { + pub offset_x: f32, + pub offset_y: f32, + pub blur_radius: f32, + pub spread_radius: f32, + pub color: Color, +} + // --------------------------------------------------------------------------- // ComputedStyle // --------------------------------------------------------------------------- @@ -803,6 +817,7 @@ pub struct ComputedStyle { pub background_position_x: LengthOrAuto, pub background_position_y: LengthOrAuto, pub background_repeat: BackgroundRepeat, + pub box_shadows: Vec, // Position pub position: Position, @@ -952,6 +967,7 @@ impl Default for ComputedStyle { background_position_x: LengthOrAuto::Percentage(0.0), background_position_y: LengthOrAuto::Percentage(0.0), background_repeat: BackgroundRepeat::Repeat, + box_shadows: Vec::new(), position: Position::Static, top: LengthOrAuto::Auto, @@ -1030,6 +1046,7 @@ impl ComputedStyle { self.transition.transitions.capacity() * std::mem::size_of::(); let animations = self.animation.animations.capacity() * std::mem::size_of::(); + let box_shadows = self.box_shadows.capacity() * std::mem::size_of::(); let anim_strings: usize = self .animation .animations @@ -1043,7 +1060,14 @@ impl ComputedStyle { .sum::() + self.custom_properties.capacity() * (std::mem::size_of::() + std::mem::size_of::>() + 8); - grid_cols + grid_rows + grid_areas + transitions + animations + anim_strings + custom_props + grid_cols + + grid_rows + + grid_areas + + transitions + + animations + + box_shadows + + anim_strings + + custom_props } } @@ -1708,6 +1732,102 @@ fn resolve_color(value: &CssValue, current_color: Color) -> Option { } } +fn parse_box_shadows( + value: &CssValue, + current_color: Color, + current_font_size: f32, + viewport: (f32, f32), +) -> Vec { + match value { + CssValue::None => return Vec::new(), + CssValue::Keyword(k) if k == "none" => return Vec::new(), + _ => {} + } + + split_shadow_layers(value) + .into_iter() + .filter_map(|layer| { + parse_box_shadow_layer(&layer, current_color, current_font_size, viewport) + }) + .collect() +} + +fn split_shadow_layers(value: &CssValue) -> Vec> { + let values = match value { + CssValue::List(values) => values.clone(), + other => vec![other.clone()], + }; + + let mut layers = vec![Vec::new()]; + for value in values { + if matches!(&value, CssValue::Keyword(k) if k == ",") { + if !layers.last().is_some_and(Vec::is_empty) { + layers.push(Vec::new()); + } + } else { + layers.last_mut().expect("at least one layer").push(value); + } + } + layers.retain(|layer| !layer.is_empty()); + layers +} + +fn parse_box_shadow_layer( + values: &[CssValue], + current_color: Color, + current_font_size: f32, + viewport: (f32, f32), +) -> Option { + let mut lengths = Vec::new(); + let mut color = None; + let mut inset = false; + + for value in values { + if matches!(value, CssValue::Keyword(k) if k == "inset") { + inset = true; + continue; + } + if let Some(c) = resolve_color(value, current_color) { + color = Some(c); + continue; + } + if let Some(px) = shadow_length_px(value, current_font_size, viewport) { + lengths.push(px); + continue; + } + return None; + } + + if inset || lengths.len() < 2 || lengths.len() > 4 { + return None; + } + + Some(BoxShadow { + offset_x: lengths[0], + offset_y: lengths[1], + blur_radius: lengths.get(2).copied().unwrap_or(0.0).max(0.0), + spread_radius: lengths.get(3).copied().unwrap_or(0.0), + color: color.unwrap_or(current_color), + }) +} + +fn shadow_length_px(value: &CssValue, current_font_size: f32, viewport: (f32, f32)) -> Option { + match value { + CssValue::Length(n, unit) => { + Some(resolve_length_unit(*n, *unit, current_font_size, viewport)) + } + CssValue::Zero => Some(0.0), + CssValue::Number(n) if *n == 0.0 => Some(0.0), + CssValue::Math(expr) => { + match resolve_math_expr_to_layout(expr, current_font_size, viewport) { + LengthOrAuto::Length(px) => Some(px), + _ => None, + } + } + _ => None, + } +} + fn parse_transform(value: &CssValue) -> Option { match value { CssValue::None => Some(Transform::None), @@ -2150,6 +2270,9 @@ fn apply_property( "background-repeat" => { style.background_repeat = background_repeat_from_value(value); } + "box-shadow" => { + style.box_shadows = parse_box_shadows(value, style.color, current_fs, viewport); + } // Position "position" => { @@ -3065,6 +3188,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed style.background_position_y = parent.background_position_y; } "background-repeat" => style.background_repeat = parent.background_repeat, + "box-shadow" => style.box_shadows = parent.box_shadows.clone(), "position" => style.position = parent.position, "float" => style.float = parent.float, "clear" => style.clear = parent.clear, @@ -3163,6 +3287,7 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { style.background_position_y = initial.background_position_y; } "background-repeat" => style.background_repeat = initial.background_repeat, + "box-shadow" => style.box_shadows = initial.box_shadows, "position" => style.position = initial.position, "top" => style.top = initial.top, "right" => style.right = initial.right, @@ -4179,6 +4304,65 @@ mod tests { assert!(div_node.style.background_image.is_none()); } + #[test] + fn author_box_shadow_parses_multiple_outer_layers() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + doc.append_child(body, div); + + let ss = Parser::parse( + "div { color: #123456; box-shadow: 0 2px 4px rgba(0,0,0,.3), 0 0 0 1px currentColor; }", + ); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + + assert_eq!(div_node.style.box_shadows.len(), 2); + assert_eq!( + div_node.style.box_shadows[0], + BoxShadow { + offset_x: 0.0, + offset_y: 2.0, + blur_radius: 4.0, + spread_radius: 0.0, + color: Color::new(0, 0, 0, 77), + } + ); + assert_eq!( + div_node.style.box_shadows[1], + BoxShadow { + offset_x: 0.0, + offset_y: 0.0, + blur_radius: 0.0, + spread_radius: 1.0, + color: Color::rgb(18, 52, 86), + } + ); + } + + #[test] + fn author_box_shadow_skips_inset_layers_until_supported() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + doc.append_child(body, div); + + let ss = Parser::parse("div { box-shadow: inset 0 1px 2px red, 4px 5px 0 blue; }"); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + + assert_eq!( + div_node.style.box_shadows, + vec![BoxShadow { + offset_x: 4.0, + offset_y: 5.0, + blur_radius: 0.0, + spread_radius: 0.0, + color: Color::rgb(0, 0, 255), + }] + ); + } + #[test] fn author_font_size_px() { let (mut doc, _, _, body) = make_doc_with_body(); -- 2.51.2