From 33580e4b2b49d30d85642e48b74d24651292b98d Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Apr 2026 06:36:52 +0900 Subject: [PATCH 1/2] feat(htmlcss): implement CSS Grid layout support Enable CSS Grid in the htmlcss direct-paint pipeline by wiring Stylo's grid properties through to Taffy's grid layout engine. - Enable Stylo's `layout.grid.enabled` pref (servo mode gates grid parsing behind this flag, defaulting to false) - Add grid IR types: GridAutoFlow, TrackBreadth, TrackSize, RepeatCount, GridTemplateEntry, GridPlacement - Collect grid container props from Stylo: grid-template-columns/rows, grid-auto-columns/rows, grid-auto-flow - Collect grid child placement: grid-column-start/end, grid-row-start/end - Convert IR to Taffy grid styles: track sizing functions (px, %, fr, minmax, fit-content), repeat(count/auto-fill/auto-fit), GridPlacement - Add 6 tests covering fixed columns, fr units, repeat(), span, dense auto-flow, and a structural layout assertion Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + crates/grida-canvas/Cargo.toml | 1 + crates/grida-canvas/src/htmlcss/collect.rs | 147 ++++++++++++++++++++ crates/grida-canvas/src/htmlcss/layout.rs | 112 +++++++++++++++ crates/grida-canvas/src/htmlcss/mod.rs | 154 +++++++++++++++++++++ crates/grida-canvas/src/htmlcss/style.rs | 22 +++ crates/grida-canvas/src/htmlcss/types.rs | 72 ++++++++++ docs/wg/feat-2d/htmlcss.md | 19 ++- 8 files changed, 527 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b04cc5f9c..763f3e6c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,7 @@ dependencies = [ "serde_json", "skia-safe", "stylo", + "stylo_config", "taffy", "tokio", "unicode-segmentation", diff --git a/crates/grida-canvas/Cargo.toml b/crates/grida-canvas/Cargo.toml index bcd500ef4..ed43ef1ab 100644 --- a/crates/grida-canvas/Cargo.toml +++ b/crates/grida-canvas/Cargo.toml @@ -43,6 +43,7 @@ usvg = { path = "../../third_party/usvg" } # html/css cascade (Stylo-based style resolution) csscascade = { path = "../csscascade" } stylo = { git = "https://github.com/gridaco/stylo" } +style_config = { git = "https://github.com/gridaco/stylo", package = "stylo_config" } # markdown parsing # (+0.25mb wasm32-unknown-emscripten@opt-level=3) pulldown-cmark = "0.13.0" diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index e7535f600..b93c20eac 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -33,6 +33,12 @@ pub fn collect_styled_tree(html: &str) -> Result, String> let _ = thread_state::initialize(ThreadState::LAYOUT); + // Enable CSS Grid support in Stylo's servo mode (one-time). + // Without this, `display: grid` is not parsed (gated behind a pref). + use std::sync::Once; + static GRID_PREF: Once = Once::new(); + GRID_PREF.call_once(|| style_config::set_bool("layout.grid.enabled", true)); + let dom = DemoDom::parse_from_bytes(html.as_bytes()).map_err(|e| format!("HTML parse error: {e}"))?; let mut driver = CascadeDriver::new(&dom); @@ -572,6 +578,21 @@ fn extract_style(tag: &str, style: &ComputedValues) -> StyledElement { el.flex_grow = style.clone_flex_grow().0; el.flex_shrink = style.clone_flex_shrink().0; + // Grid container + if el.display == types::Display::Grid { + el.grid_template_columns = extract_grid_template(&style.clone_grid_template_columns()); + el.grid_template_rows = extract_grid_template(&style.clone_grid_template_rows()); + el.grid_auto_columns = extract_implicit_tracks(&style.clone_grid_auto_columns()); + el.grid_auto_rows = extract_implicit_tracks(&style.clone_grid_auto_rows()); + el.grid_auto_flow = extract_grid_auto_flow(&style.clone_grid_auto_flow()); + } + + // Grid child + el.grid_column_start = extract_grid_placement(&style.clone_grid_column_start()); + el.grid_column_end = extract_grid_placement(&style.clone_grid_column_end()); + el.grid_row_start = extract_grid_placement(&style.clone_grid_row_start()); + el.grid_row_end = extract_grid_placement(&style.clone_grid_row_end()); + el } @@ -924,6 +945,132 @@ fn auto_distribute_stops(raw: &mut [(Option, CGColor)]) { } } +// ─── Grid property extraction ─────────────────────────────────────── + +/// Convert a Stylo `GridTemplateComponent` (computed) to our IR. +/// +/// Stylo's computed grid-template uses `CSSInteger` (= `i32`) for repeat counts. +fn extract_grid_template( + tpl: &style::values::generics::grid::GenericGridTemplateComponent< + style::values::computed::LengthPercentage, + i32, + >, +) -> Vec { + use style::values::generics::grid::GenericGridTemplateComponent; + match tpl { + GenericGridTemplateComponent::None | GenericGridTemplateComponent::Masonry => Vec::new(), + GenericGridTemplateComponent::Subgrid(_) => Vec::new(), // subgrid not supported + GenericGridTemplateComponent::TrackList(track_list) => { + use style::values::generics::grid::GenericTrackListValue; + let mut entries = Vec::new(); + for value in track_list.values.iter() { + match value { + GenericTrackListValue::TrackSize(ts) => { + entries.push(types::GridTemplateEntry::Track(stylo_track_size(ts))); + } + GenericTrackListValue::TrackRepeat(rep) => { + use style::values::generics::grid::RepeatCount; + let count = match rep.count { + RepeatCount::Number(n) => types::RepeatCount::Count(n as u16), + RepeatCount::AutoFill => types::RepeatCount::AutoFill, + RepeatCount::AutoFit => types::RepeatCount::AutoFit, + }; + let tracks: Vec = rep + .track_sizes + .iter() + .map(|ts| stylo_track_size(ts)) + .collect(); + entries.push(types::GridTemplateEntry::Repeat(count, tracks)); + } + } + } + entries + } + } +} + +/// Convert Stylo `ImplicitGridTracks` (grid-auto-columns/rows) to our IR. +fn extract_implicit_tracks( + tracks: &style::values::generics::grid::GenericImplicitGridTracks< + style::values::generics::grid::GenericTrackSize, + >, +) -> Vec { + tracks.0.iter().map(|ts| stylo_track_size(ts)).collect() +} + +/// Convert a single Stylo `TrackSize` to our IR. +fn stylo_track_size( + ts: &style::values::generics::grid::GenericTrackSize, +) -> types::TrackSize { + use style::values::generics::grid::GenericTrackSize; + match ts { + GenericTrackSize::Breadth(b) => types::TrackSize::Single(stylo_track_breadth(b)), + GenericTrackSize::Minmax(min_b, max_b) => { + types::TrackSize::MinMax(stylo_track_breadth(min_b), stylo_track_breadth(max_b)) + } + GenericTrackSize::FitContent(b) => types::TrackSize::FitContent(stylo_track_breadth(b)), + } +} + +/// Convert a single Stylo `TrackBreadth` to our IR. +fn stylo_track_breadth( + b: &style::values::generics::grid::GenericTrackBreadth< + style::values::computed::LengthPercentage, + >, +) -> types::TrackBreadth { + use style::values::generics::grid::GenericTrackBreadth; + match b { + GenericTrackBreadth::Breadth(lp) => { + if let Some(len) = lp.to_length() { + types::TrackBreadth::Px(len.px()) + } else if let Some(pct) = lp.to_percentage() { + types::TrackBreadth::Percent(pct.0) + } else { + types::TrackBreadth::Auto + } + } + GenericTrackBreadth::Fr(fr) => types::TrackBreadth::Fr(*fr), + GenericTrackBreadth::Auto => types::TrackBreadth::Auto, + GenericTrackBreadth::MinContent => types::TrackBreadth::MinContent, + GenericTrackBreadth::MaxContent => types::TrackBreadth::MaxContent, + } +} + +/// Convert Stylo `GridAutoFlow` to our IR. +fn extract_grid_auto_flow( + flow: &style::values::specified::position::GridAutoFlow, +) -> types::GridAutoFlow { + let is_column = flow.contains(style::values::specified::position::GridAutoFlow::COLUMN); + let is_dense = flow.contains(style::values::specified::position::GridAutoFlow::DENSE); + match (is_column, is_dense) { + (false, false) => types::GridAutoFlow::Row, + (false, true) => types::GridAutoFlow::RowDense, + (true, false) => types::GridAutoFlow::Column, + (true, true) => types::GridAutoFlow::ColumnDense, + } +} + +/// Convert a Stylo `GridLine` (grid-column-start/end, grid-row-start/end) to our IR. +/// +/// Stylo's computed grid-line uses `CSSInteger` (= `i32`) for line numbers. +fn extract_grid_placement( + line: &style::values::generics::grid::GenericGridLine, +) -> types::GridPlacement { + if line.is_auto() { + return types::GridPlacement::Auto; + } + if line.is_span { + let n = line.line_num.unsigned_abs() as u16; + return types::GridPlacement::Span(if n == 0 { 1 } else { n }); + } + let num = line.line_num; + if num != 0 { + return types::GridPlacement::Line(num as i16); + } + // line_num == 0 with ident only → treat as auto (named lines not supported) + types::GridPlacement::Auto +} + fn extract_font(style: &ComputedValues) -> FontProps { let font = style.get_font(); let inherited_text = style.get_inherited_text(); diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index 6bc43817d..3fd6aa4aa 100644 --- a/crates/grida-canvas/src/htmlcss/layout.rs +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -326,6 +326,30 @@ fn element_to_taffy_style(el: &StyledElement) -> taffy::Style { ..taffy::Style::default() }; + // Grid container properties + if el.display == types::Display::Grid { + style.grid_template_columns = grid_template_to_taffy(&el.grid_template_columns); + style.grid_template_rows = grid_template_to_taffy(&el.grid_template_rows); + style.grid_auto_columns = implicit_tracks_to_taffy(&el.grid_auto_columns); + style.grid_auto_rows = implicit_tracks_to_taffy(&el.grid_auto_rows); + style.grid_auto_flow = match el.grid_auto_flow { + types::GridAutoFlow::Row => taffy::GridAutoFlow::Row, + types::GridAutoFlow::Column => taffy::GridAutoFlow::Column, + types::GridAutoFlow::RowDense => taffy::GridAutoFlow::RowDense, + types::GridAutoFlow::ColumnDense => taffy::GridAutoFlow::ColumnDense, + }; + } + + // Grid child placement + style.grid_column = taffy::Line { + start: grid_placement_to_taffy(el.grid_column_start), + end: grid_placement_to_taffy(el.grid_column_end), + }; + style.grid_row = taffy::Line { + start: grid_placement_to_taffy(el.grid_row_start), + end: grid_placement_to_taffy(el.grid_row_end), + }; + // Faux-table: override display/flex for CSS table elements. // Try display-based override first, then tag-based fallback for // row-group wrappers (, , ). @@ -383,6 +407,94 @@ fn map_overflow(ov: types::Overflow) -> taffy::Overflow { } } +// ─── Grid conversion helpers ──────────────────────────────────────── + +fn track_breadth_to_taffy_min(b: types::TrackBreadth) -> taffy::MinTrackSizingFunction { + match b { + types::TrackBreadth::Px(px) => taffy::MinTrackSizingFunction::length(px), + types::TrackBreadth::Percent(pct) => taffy::MinTrackSizingFunction::percent(pct), + // fr is not valid for min track sizing — treat as auto + types::TrackBreadth::Fr(_) => taffy::MinTrackSizingFunction::auto(), + types::TrackBreadth::Auto => taffy::MinTrackSizingFunction::auto(), + types::TrackBreadth::MinContent => taffy::MinTrackSizingFunction::min_content(), + types::TrackBreadth::MaxContent => taffy::MinTrackSizingFunction::max_content(), + } +} + +fn track_breadth_to_taffy_max(b: types::TrackBreadth) -> taffy::MaxTrackSizingFunction { + match b { + types::TrackBreadth::Px(px) => taffy::MaxTrackSizingFunction::length(px), + types::TrackBreadth::Percent(pct) => taffy::MaxTrackSizingFunction::percent(pct), + types::TrackBreadth::Fr(fr) => taffy::MaxTrackSizingFunction::fr(fr), + types::TrackBreadth::Auto => taffy::MaxTrackSizingFunction::auto(), + types::TrackBreadth::MinContent => taffy::MaxTrackSizingFunction::min_content(), + types::TrackBreadth::MaxContent => taffy::MaxTrackSizingFunction::max_content(), + } +} + +fn track_size_to_taffy(ts: &types::TrackSize) -> taffy::TrackSizingFunction { + match ts { + types::TrackSize::Single(b) => taffy::MinMax { + min: track_breadth_to_taffy_min(*b), + max: track_breadth_to_taffy_max(*b), + }, + types::TrackSize::MinMax(min_b, max_b) => taffy::MinMax { + min: track_breadth_to_taffy_min(*min_b), + max: track_breadth_to_taffy_max(*max_b), + }, + types::TrackSize::FitContent(b) => { + let max = match b { + types::TrackBreadth::Px(px) => taffy::MaxTrackSizingFunction::fit_content_px(*px), + types::TrackBreadth::Percent(pct) => { + taffy::MaxTrackSizingFunction::fit_content_percent(*pct) + } + _ => taffy::MaxTrackSizingFunction::auto(), + }; + taffy::MinMax { + min: taffy::MinTrackSizingFunction::auto(), + max, + } + } + } +} + +fn grid_template_to_taffy( + entries: &[types::GridTemplateEntry], +) -> Vec> { + entries + .iter() + .map(|entry| match entry { + types::GridTemplateEntry::Track(ts) => { + taffy::GridTemplateComponent::Single(track_size_to_taffy(ts)) + } + types::GridTemplateEntry::Repeat(count, tracks) => { + taffy::GridTemplateComponent::Repeat(taffy::GridTemplateRepetition { + count: match count { + types::RepeatCount::Count(n) => taffy::RepetitionCount::Count(*n), + types::RepeatCount::AutoFill => taffy::RepetitionCount::AutoFill, + types::RepeatCount::AutoFit => taffy::RepetitionCount::AutoFit, + }, + tracks: tracks.iter().map(|ts| track_size_to_taffy(ts)).collect(), + line_names: Vec::new(), + }) + } + }) + .collect() +} + +fn implicit_tracks_to_taffy(tracks: &[types::TrackSize]) -> Vec { + tracks.iter().map(|ts| track_size_to_taffy(ts)).collect() +} + +fn grid_placement_to_taffy(p: types::GridPlacement) -> taffy::GridPlacement { + use taffy::style_helpers::TaffyGridLine; + match p { + types::GridPlacement::Auto => taffy::GridPlacement::Auto, + types::GridPlacement::Line(n) => taffy::GridPlacement::from_line_index(n), + types::GridPlacement::Span(n) => taffy::GridPlacement::Span(n), + } +} + // ─── Layout extraction ─────────────────────────────────────────────── fn extract_layout<'a>( diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs index 23d2714c1..91883b5f9 100644 --- a/crates/grida-canvas/src/htmlcss/mod.rs +++ b/crates/grida-canvas/src/htmlcss/mod.rs @@ -146,6 +146,160 @@ mod tests { assert!(pic.is_ok()); } + /// Verify grid properties are collected and layout produces columns. + #[test] + fn test_grid_layout_columns() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + + let html = r#"
+
A
B
C
+
"#; + + // Check collection — walk down html>body>div to find the grid container + let root = collect::collect_styled_tree(html).unwrap().unwrap(); + fn find_grid(el: &style::StyledElement) -> Option<&style::StyledElement> { + if el.display == types::Display::Grid { + return Some(el); + } + for child in &el.children { + if let style::StyledNode::Element(child_el) = child { + if let Some(found) = find_grid(child_el) { + return Some(found); + } + } + } + None + } + let grid_el = find_grid(&root).expect("Should find a grid container in the tree"); + assert!( + !grid_el.grid_template_columns.is_empty(), + "grid-template-columns should be collected", + ); + + // Check layout produces side-by-side boxes + let layout_root = layout::compute_layout(&root, 400.0, &fonts); + fn find_grid_layout<'a>( + lb: &'a layout::LayoutBox<'a>, + ) -> Option<&'a layout::LayoutBox<'a>> { + if lb.style.display == types::Display::Grid { + return Some(lb); + } + for child in &lb.children { + if let layout::LayoutNode::Box(child_box) = child { + if let Some(found) = find_grid_layout(child_box) { + return Some(found); + } + } + } + None + } + let grid_layout = find_grid_layout(&layout_root).expect("Should find grid in layout"); + + // A, B, C should be in 3 columns at x=0, x=100, x=200 + let xs: Vec = grid_layout + .children + .iter() + .filter_map(|c| match c { + layout::LayoutNode::Box(b) => Some(b.x), + _ => None, + }) + .collect(); + assert_eq!(xs.len(), 3, "Expected 3 box children, got {}", xs.len()); + assert!( + xs[1] > xs[0], + "Column 2 should be right of column 1 (x[1]={} > x[0]={})", + xs[1], + xs[0] + ); + assert!( + xs[2] > xs[1], + "Column 3 should be right of column 2 (x[2]={} > x[1]={})", + xs[2], + xs[1] + ); + } + + #[test] + fn test_render_grid_basic() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
+
A
B
C
+
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_grid_fr() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
+
1fr
2fr
1fr
+
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_grid_repeat() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
+
1
2
3
4
+
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_grid_span() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
+
wide
+
1x1
+
1x1
+
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_grid_auto_flow_dense() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
+
wide
+
a
+
b
+
wide
+
"#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + #[test] fn test_render_opacity() { let _guard = TEST_LOCK.lock().unwrap(); diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index 890c1ce73..24296b480 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -91,6 +91,19 @@ pub struct StyledElement { pub flex_basis: CssLength, pub align_self: Option, + // ── Grid container (rare non-inherited) ── + pub grid_template_columns: Vec, + pub grid_template_rows: Vec, + pub grid_auto_columns: Vec, + pub grid_auto_rows: Vec, + pub grid_auto_flow: GridAutoFlow, + + // ── Grid child (rare non-inherited) ── + pub grid_column_start: GridPlacement, + pub grid_column_end: GridPlacement, + pub grid_row_start: GridPlacement, + pub grid_row_end: GridPlacement, + // ── Children ── pub children: Vec, } @@ -443,6 +456,15 @@ impl Default for StyledElement { flex_shrink: 1.0, flex_basis: CssLength::Auto, align_self: None, + grid_template_columns: Vec::new(), + grid_template_rows: Vec::new(), + grid_auto_columns: Vec::new(), + grid_auto_rows: Vec::new(), + grid_auto_flow: GridAutoFlow::default(), + grid_column_start: GridPlacement::default(), + grid_column_end: GridPlacement::default(), + grid_row_start: GridPlacement::default(), + grid_row_end: GridPlacement::default(), children: Vec::new(), } } diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs index d475b987f..92d98ff4c 100644 --- a/crates/grida-canvas/src/htmlcss/types.rs +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -212,3 +212,75 @@ pub enum ListStylePosition { Outside, Inside, } + +// ─── CSS Grid types ───────────────────────────────────────────────── + +/// CSS `grid-auto-flow` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GridAutoFlow { + #[default] + Row, + Column, + RowDense, + ColumnDense, +} + +/// A single track sizing value — used in grid-template-columns/rows. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrackBreadth { + /// Fixed length in px. + Px(f32), + /// Percentage (0.0–1.0). + Percent(f32), + /// Flexible `fr` unit. + Fr(f32), + /// `auto` + Auto, + /// `min-content` + MinContent, + /// `max-content` + MaxContent, +} + +/// A track sizing function — `` in CSS. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrackSize { + /// A single breadth value (used for both min and max). + Single(TrackBreadth), + /// `minmax(min, max)`. + MinMax(TrackBreadth, TrackBreadth), + /// `fit-content(limit)`. + FitContent(TrackBreadth), +} + +/// A `repeat()` count in grid-template-columns/rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepeatCount { + /// `repeat(, ...)` + Count(u16), + /// `repeat(auto-fill, ...)` + AutoFill, + /// `repeat(auto-fit, ...)` + AutoFit, +} + +/// A single component in a grid-template-columns/rows definition. +#[derive(Debug, Clone, PartialEq)] +pub enum GridTemplateEntry { + /// A single track sizing function. + Track(TrackSize), + /// `repeat(count, tracks...)`. + Repeat(RepeatCount, Vec), +} + +/// CSS grid-column/row placement for an item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GridPlacement { + /// `auto` + #[default] + Auto, + /// A line number (1-based, can be negative). + Line(i16), + /// `span `. + Span(u16), +} diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index 31b3784e8..d9d22031e 100644 --- a/docs/wg/feat-2d/htmlcss.md +++ b/docs/wg/feat-2d/htmlcss.md @@ -74,11 +74,28 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: | `display: inline` | ✅ | Merged into parent's Paragraph as InlineRunItem | | `display: none` | ✅ | Subtree skipped | | `display: flex` | ✅ | Via Taffy — direction, wrap, align, justify, gap | -| `display: grid` | ✅ | Via Taffy `Display::Grid` | +| `display: grid` | ✅ | Via Taffy `Display::Grid` — full property support | | `display: list-item` | ✅ | Marker text generated (bullet/number) | | `display: table` | ⚠️ | Falls back to block flow (no column grid) | | `display: inline-block` | ⚠️ | Treated as inline | +### Grid Layout + +| CSS Property | Status | Notes | +| ------------------------- | ------ | -------------------------------------------------- | +| `grid-template-columns` | ✅ | px, %, fr, minmax(), fit-content(), repeat() | +| `grid-template-rows` | ✅ | px, %, fr, minmax(), fit-content(), repeat() | +| `grid-auto-columns` | ✅ | Implicit track sizing | +| `grid-auto-rows` | ✅ | Implicit track sizing | +| `grid-auto-flow` | ✅ | row, column, dense | +| `grid-column` (start/end) | ✅ | Line numbers, span | +| `grid-row` (start/end) | ✅ | Line numbers, span | +| `repeat(auto-fill, ...)` | ✅ | Via Taffy | +| `repeat(auto-fit, ...)` | ✅ | Via Taffy | +| `grid-template-areas` | ❌ | Not collected from Stylo (named areas not mapped) | +| Named grid lines | ❌ | Line names ignored; numeric placement only | +| `subgrid` | ❌ | Taffy does not support subgrid | + ### Box Model | CSS Property | Status | Notes | From e3f8c5f1b457f48af27b2c5267b65811a6718fe6 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 7 Apr 2026 06:46:09 +0900 Subject: [PATCH 2/2] feat(htmlcss): implement box-shadow collection and inset painting - Collect box-shadow from Stylo (offset, blur, spread, color, inset) via clone_box_shadow() on ComputedValues - Implement inset box-shadow painting using clip + EvenOdd PathBuilder frame (outer rect minus inner rect, blurred) - Multiple shadows (outer + inset) now render correctly - Add 4 tests: outer, inset, combined, and property collection assertion - Update docs to reflect full box-shadow support Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/grida-canvas/src/htmlcss/collect.rs | 27 +++++++ crates/grida-canvas/src/htmlcss/mod.rs | 70 ++++++++++++++++++ crates/grida-canvas/src/htmlcss/paint.rs | 85 ++++++++++++++++++++-- docs/wg/feat-2d/htmlcss.md | 8 +- 4 files changed, 182 insertions(+), 8 deletions(-) diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index b93c20eac..657a8a0ae 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -515,6 +515,9 @@ fn extract_style(tag: &str, style: &ComputedValues) -> StyledElement { // Font properties (inherited) el.font = extract_font(style); + // Box shadow + el.box_shadow = extract_box_shadow(style); + // Blend mode el.blend_mode = extract_blend_mode(style); @@ -945,6 +948,30 @@ fn auto_distribute_stops(raw: &mut [(Option, CGColor)]) { } } +fn extract_box_shadow(style: &ComputedValues) -> Vec { + let shadows = style.clone_box_shadow(); + shadows + .0 + .iter() + .map(|s| { + let color = s + .base + .color + .as_absolute() + .map(|a| abs_color_to_cg(a)) + .unwrap_or(CGColor::BLACK); + BoxShadow { + offset_x: s.base.horizontal.px(), + offset_y: s.base.vertical.px(), + blur: s.base.blur.0.px(), + spread: s.spread.px(), + color, + inset: s.inset, + } + }) + .collect() +} + // ─── Grid property extraction ─────────────────────────────────────── /// Convert a Stylo `GridTemplateComponent` (computed) to our IR. diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs index 91883b5f9..08cf029f6 100644 --- a/crates/grida-canvas/src/htmlcss/mod.rs +++ b/crates/grida-canvas/src/htmlcss/mod.rs @@ -300,6 +300,76 @@ mod tests { assert!(pic.is_ok()); } + #[test] + fn test_render_box_shadow_outer() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
shadow
"#, + 300.0, + 200.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_box_shadow_inset() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
inset
"#, + 300.0, + 200.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_box_shadow_combined() { + let _guard = TEST_LOCK.lock().unwrap(); + let fonts = test_fonts(); + let pic = render( + r#"
both
"#, + 300.0, + 200.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + /// Verify box-shadow properties are collected from Stylo. + #[test] + fn test_box_shadow_collection() { + let _guard = TEST_LOCK.lock().unwrap(); + + let html = r#"
shadow
"#; + let root = collect::collect_styled_tree(html).unwrap().unwrap(); + + fn find_shadow_el(el: &style::StyledElement) -> Option<&style::StyledElement> { + if !el.box_shadow.is_empty() { + return Some(el); + } + for child in &el.children { + if let style::StyledNode::Element(child_el) = child { + if let Some(found) = find_shadow_el(child_el) { + return Some(found); + } + } + } + None + } + let el = find_shadow_el(&root).expect("Should find element with box-shadow"); + assert_eq!(el.box_shadow.len(), 1); + let s = &el.box_shadow[0]; + assert!((s.offset_x - 4.0).abs() < 0.01); + assert!((s.offset_y - 6.0).abs() < 0.01); + assert!((s.blur - 8.0).abs() < 0.01); + assert!((s.spread - 2.0).abs() < 0.01); + assert!(!s.inset); + } + #[test] fn test_render_opacity() { let _guard = TEST_LOCK.lock().unwrap(); diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index 416b224aa..d668bfacd 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -371,15 +371,90 @@ fn paint_box_shadow_outer(canvas: &Canvas, style: &StyledElement, w: f32, h: f32 } } -fn paint_box_shadow_inset(_canvas: &Canvas, style: &StyledElement, _w: f32, _h: f32) { +/// Paint inset box-shadows (Chromium: BoxPainterBase::PaintInsetBoxShadow). +/// +/// Inset shadows render *inside* the element by clipping to the box bounds, +/// then drawing a hollow rect (the box outline expanded outward) with a blur +/// mask so that only the soft inner edge is visible. +fn paint_box_shadow_inset(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { for shadow in &style.box_shadow { if !shadow.inset { continue; } - // TODO: inset box shadows require clipping to the box bounds - // and drawing the shadow inside. This is more complex than outer - // shadows and requires a save/clip/draw/restore pattern. - let _ = shadow; + + let box_rect = Rect::from_xywh(0.0, 0.0, w, h); + + // Clip to the box so shadow cannot bleed outside + canvas.save(); + let r = &style.border_radius; + if r.is_zero() { + canvas.clip_rect(box_rect, ClipOp::Intersect, true); + } else { + let mut clip_rrect = skia_safe::RRect::new(); + clip_rrect.set_rect_radii(box_rect, &r.to_skia_radii()); + canvas.clip_rrect(clip_rrect, ClipOp::Intersect, true); + } + + let mut paint = Paint::default(); + paint.set_color(Color::from_argb( + shadow.color.a, + shadow.color.r, + shadow.color.g, + shadow.color.b, + )); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + if shadow.blur > 0.0 { + paint.set_mask_filter(skia_safe::MaskFilter::blur( + skia_safe::BlurStyle::Normal, + shadow.blur / 2.0, + false, + )); + } + + // Draw a large rect with a hole cut out, shifted by offset + spread. + // The blur on the outer edge of the hole creates the inset shadow. + let spread = shadow.spread; + let inner_rect = Rect::from_xywh( + shadow.offset_x + spread, + shadow.offset_y + spread, + w - spread * 2.0, + h - spread * 2.0, + ); + + // Outer rect large enough that its edges are outside the clip region + let expansion = shadow.blur * 2.0 + shadow.spread.abs() + 100.0; + let outer_rect = Rect::from_xywh( + -expansion + shadow.offset_x, + -expansion + shadow.offset_y, + w + expansion * 2.0, + h + expansion * 2.0, + ); + + // Build a path: outer rect minus inner rect (creates a frame). + // EvenOdd fill makes the inner rect a hole. + let mut builder = + skia_safe::PathBuilder::new_with_fill_type(skia_safe::PathFillType::EvenOdd); + builder.add_rect(outer_rect, None, None); + if r.is_zero() { + builder.add_rect(inner_rect, None, None); + } else { + // Shrink corner radii by spread for the inner cutout + let shrink = spread.max(0.0); + let inner_radii = [ + skia_safe::Point::new((r.tl_x - shrink).max(0.0), (r.tl_y - shrink).max(0.0)), + skia_safe::Point::new((r.tr_x - shrink).max(0.0), (r.tr_y - shrink).max(0.0)), + skia_safe::Point::new((r.br_x - shrink).max(0.0), (r.br_y - shrink).max(0.0)), + skia_safe::Point::new((r.bl_x - shrink).max(0.0), (r.bl_y - shrink).max(0.0)), + ]; + let mut inner_rrect = skia_safe::RRect::new(); + inner_rrect.set_rect_radii(inner_rect, &inner_radii); + builder.add_rrect(&inner_rrect, None, None); + } + let path = builder.detach(); + + canvas.draw_path(&path, &paint); + canvas.restore(); } } diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index d9d22031e..16193e92c 100644 --- a/docs/wg/feat-2d/htmlcss.md +++ b/docs/wg/feat-2d/htmlcss.md @@ -269,6 +269,8 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: | `visibility` | ✅ | hidden/collapse skips painting | | `overflow` | ✅ | hidden/clip via canvas clip_rect | | `box-shadow` (outer) | ✅ | blur, spread, offset, border-radius | +| `box-shadow` (inset) | ✅ | clip + EvenOdd frame via PathBuilder | +| `box-shadow` (multi) | ✅ | Multiple shadows stacked | | `mix-blend-mode` | ✅ | All CSS blend modes | ### Positioning @@ -335,9 +337,9 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: | CSS Property | Status | Notes | | -------------------- | ------ | ----------------------------------- | -| `box-shadow` (outer) | ✅ | blur, spread, offset, border-radius | -| `box-shadow: inset` | ❌ | | -| Multiple shadows | ❌ | Only first shadow painted | +| `box-shadow` (outer) | ✅ | blur, spread, offset, border-radius | +| `box-shadow: inset` | ✅ | clip + EvenOdd frame via PathBuilder | +| Multiple shadows | ✅ | All shadows stacked in order | ### Positioning (extended)