diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h index cf1a1f6a3..6c6cea7ab 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h @@ -61,15 +61,32 @@ inline bool grid_layout_equal( } return true; }; + const auto areas_equal = [](const auto& left_areas, const auto& right_areas) { + if (left_areas.size() != right_areas.size()) return false; + for (size_t index = 0; index < left_areas.size(); ++index) { + const auto& left_area = left_areas[index]; + const auto& right_area = right_areas[index]; + if (left_area.name != right_area.name + || left_area.row_start != right_area.row_start + || left_area.row_end != right_area.row_end + || left_area.column_start != right_area.column_start + || left_area.column_end != right_area.column_end) return false; + } + return true; + }; return tracks_equal(left.template_columns, right.template_columns) && tracks_equal(left.template_rows, right.template_rows) && tracks_equal(left.auto_columns, right.auto_columns) + && areas_equal(left.template_areas, right.template_areas) + && left.template_area_row_count == right.template_area_row_count + && left.template_area_column_count == right.template_area_column_count && left.subgrid_columns == right.subgrid_columns && left.two_columns == right.two_columns && left.auto_flow_column == right.auto_flow_column && left.fractional_rows == right.fractional_rows && left.span_all == right.span_all && left.column_start == right.column_start + && left.template_areas_value == right.template_areas_value && left.area_value == right.area_value && left.row_value == right.row_value && left.row_start_value == right.row_start_value diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_layout_values.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_layout_values.h index 409bbc676..076c7ede1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_layout_values.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_layout_values.h @@ -150,6 +150,130 @@ inline std::vector parse_simple_grid_tracks( return tracks; } +struct parsed_grid_template_areas final { + std::vector areas; + size_t row_count{0}; + size_t column_count{0}; + std::string serialized{"none"}; +}; + +inline std::optional parse_grid_template_areas( + std::string_view value) +{ + const auto trim = [](std::string_view input) { + const auto first = input.find_first_not_of(" \t\r\n\f"); + if (first == std::string_view::npos) return std::string_view{}; + return input.substr(first, input.find_last_not_of(" \t\r\n\f") - first + 1U); + }; + value = trim(value); + if (value == "none") return parsed_grid_template_areas{}; + if (value.empty()) return std::nullopt; + + std::vector> rows; + std::string serialized; + size_t cursor = 0; + while (cursor < value.size()) { + while (cursor < value.size() + && std::isspace(static_cast(value[cursor]))) ++cursor; + if (cursor == value.size()) break; + const auto quote = value[cursor]; + if (quote != '\'' && quote != '"') return std::nullopt; + ++cursor; + std::string row_text; + auto closed = false; + while (cursor < value.size()) { + const auto character = value[cursor++]; + if (character == quote) { + closed = true; + break; + } + if (character == '\\') { + if (cursor == value.size()) return std::nullopt; + row_text.push_back(value[cursor++]); + continue; + } + if (character == '\n' || character == '\r' || character == '\f') { + return std::nullopt; + } + row_text.push_back(character); + } + if (!closed) return std::nullopt; + + std::vector cells; + std::istringstream tokens(row_text); + std::string token; + while (tokens >> token) { + const auto empty_cell = std::all_of( + token.begin(), token.end(), [](char character) { return character == '.'; }); + if (empty_cell) token = "."; + else { + const auto first = static_cast(token.front()); + if (!(std::isalpha(first) || first == '_' || first == '-')) return std::nullopt; + if (!std::all_of(token.begin() + 1, token.end(), [](unsigned char character) { + return std::isalnum(character) || character == '_' || character == '-'; + })) return std::nullopt; + if (token == "auto" || token == "span" || token == "initial" + || token == "inherit" || token == "unset" || token == "revert" + || token == "revert-layer" || token == "default") return std::nullopt; + } + cells.push_back(std::move(token)); + } + if (cells.empty()) return std::nullopt; + if (!rows.empty() && cells.size() != rows.front().size()) return std::nullopt; + if (!serialized.empty()) serialized.push_back(' '); + serialized.push_back('"'); + for (size_t index = 0; index < cells.size(); ++index) { + if (index != 0) serialized.push_back(' '); + serialized += cells[index]; + } + serialized.push_back('"'); + rows.push_back(std::move(cells)); + } + if (rows.empty()) return std::nullopt; + + parsed_grid_template_areas result; + result.row_count = rows.size(); + result.column_count = rows.front().size(); + result.serialized = std::move(serialized); + for (size_t row = 0; row < rows.size(); ++row) { + for (size_t column = 0; column < rows[row].size(); ++column) { + const auto& name = rows[row][column]; + if (name == ".") continue; + auto found = std::find_if(result.areas.begin(), result.areas.end(), + [&](const auto& area) { return area.name == name; }); + if (found == result.areas.end()) { + result.areas.push_back({name, row, row + 1U, column, column + 1U}); + } else { + found->row_start = std::min(found->row_start, row); + found->row_end = std::max(found->row_end, row + 1U); + found->column_start = std::min(found->column_start, column); + found->column_end = std::max(found->column_end, column + 1U); + } + } + } + for (const auto& area : result.areas) { + for (size_t row = area.row_start; row < area.row_end; ++row) { + for (size_t column = area.column_start; column < area.column_end; ++column) { + if (rows[row][column] != area.name) return std::nullopt; + } + } + } + return result; +} + +inline bool apply_grid_template_areas(node_style& style, const std::string& value) +{ + const auto parsed = parse_grid_template_areas(value); + if (!parsed.has_value()) return false; + auto& grid = style.mutable_grid(); + grid.template_areas = parsed->areas; + grid.template_area_row_count = parsed->row_count; + grid.template_area_column_count = parsed->column_count; + grid.template_areas_value = parsed->serialized; + grid.two_columns = grid.subgrid_columns || grid.template_columns.size() > 1U + || parsed->column_count > 1U; + return true; +} inline bool apply_grid_placement_declaration( node_style& style, @@ -200,12 +324,25 @@ inline bool apply_grid_placement_declaration( const auto component = [&](size_t index) { return index < components.size() ? components[index] : std::string{"auto"}; }; + const auto custom_identifier = [](const std::string& token) { + if (token.empty() || token == "auto" || token == "span" + || token == "inherit" || token == "initial" || token == "unset" + || token == "revert" || token == "revert-layer" + || token.starts_with("span ")) return false; + int32_t integer = 0; + const auto parsed = std::from_chars( + token.data(), token.data() + token.size(), integer); + return parsed.ec != std::errc{} || parsed.ptr != token.data() + token.size(); + }; if (name == "grid-area") { grid.area_value = value; grid.row_start_value = component(0); - grid.column_start_value = component(1); - grid.row_end_value = component(2); - grid.column_end_value = component(3); + const auto omitted = components.size() == 1U + && custom_identifier(grid.row_start_value) + ? grid.row_start_value : std::string{"auto"}; + grid.column_start_value = components.size() > 1U ? component(1) : omitted; + grid.row_end_value = components.size() > 2U ? component(2) : omitted; + grid.column_end_value = components.size() > 3U ? component(3) : omitted; grid.row_value = grid.row_start_value + " / " + grid.row_end_value; update_column_layout(); @@ -214,12 +351,16 @@ inline bool apply_grid_placement_declaration( if (name == "grid-row") { grid.row_value = value; grid.row_start_value = component(0); - grid.row_end_value = component(1); + grid.row_end_value = components.size() > 1U ? component(1) + : custom_identifier(grid.row_start_value) + ? grid.row_start_value : std::string{"auto"}; return true; } grid.column_start_value = component(0); - grid.column_end_value = component(1); + grid.column_end_value = components.size() > 1U ? component(1) + : custom_identifier(grid.column_start_value) + ? grid.column_start_value : std::string{"auto"}; update_column_layout(); // Preserve the authored one-component shorthand serialization rather // than inflating `2` to `2 / auto`. @@ -231,7 +372,15 @@ template bool apply_grid_value(dom_node& node,const std::string& name,const std::string& value, Decision& decision,Protected&& is_inline) { - if (name == "grid-template-columns" && !is_inline(inline_grid)) { + if (name == "grid-template-areas" && !is_inline(inline_grid)) { + if (!apply_grid_template_areas(node.style, value)) { + decision.classification = "unsupported"; + decision.semantic_slice = "invalid grid-template-areas declarations are ignored"; + return true; + } + decision.classification = "supported"; + decision.semantic_slice = "rectangular named grid template areas"; + } else if (name == "grid-template-columns" && !is_inline(inline_grid)) { auto& grid = node.style.mutable_grid(); const auto first = value.find_first_not_of(" \t\r\n"); const auto last = value.find_last_not_of(" \t\r\n"); @@ -245,6 +394,7 @@ bool apply_grid_value(dom_node& node,const std::string& name,const std::string& : parse_simple_grid_tracks(value); grid.two_columns = grid.subgrid_columns || grid.template_columns.size() > 1U + || grid.template_area_column_count > 1U || (grid.template_columns.empty() && has_multiple_grid_columns(value)); decision.classification = "partially-supported"; decision.semantic_slice = diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h index 6440b1e53..aece06914 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h @@ -106,6 +106,7 @@ inline uint64_t property_mask(std::string_view name) if (name == "flex-basis") return inline_flex_basis; if (name == "flex-wrap" || name == "flexWrap") return inline_flex_wrap; if (name == "grid-template-columns" || name == "grid-template-rows" + || name == "grid-template-areas" || name == "grid-area" || name == "grid-row" || name == "grid-row-start" || name == "grid-row-end" || name == "grid-column" || name == "grid-column-start" diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index 196be1f4e..69d2bad87 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -219,7 +219,7 @@ inline void apply_all_unset(dom_node& node) reset.z_index_auto = previous.z_index_auto; } if (has_inline({"flex-basis", "flex"})) reset.flex_basis = previous.flex_basis; - if (has_inline({"grid-template-columns", "grid-template-rows", + if (has_inline({"grid-template-columns", "grid-template-rows", "grid-template-areas", "grid-auto-columns", "grid-auto-flow", "grid-area", "grid-row", "grid-row-start", "grid-row-end", "grid-column", "grid-column-start", "grid-column-end"})) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 6d2bea365..7bf6209aa 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -309,9 +309,20 @@ struct node_style final { sizing kind{sizing::automatic}; }; + struct named_area final { + std::string name; + size_t row_start{0}; + size_t row_end{0}; + size_t column_start{0}; + size_t column_end{0}; + }; + std::vector template_columns; std::vector template_rows; std::vector auto_columns; + std::vector template_areas; + size_t template_area_row_count{0}; + size_t template_area_column_count{0}; bool subgrid_columns{false}; bool two_columns{false}; bool auto_flow_column{false}; @@ -319,6 +330,7 @@ struct node_style final { bool span_all{false}; bool compiled_full_columns{false}; int32_t column_start{0}; + std::string template_areas_value{"none"}; std::string area_value{"auto"}; std::string row_value{"auto"}; std::string row_start_value{"auto"}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc index ff8be1769..7138fb210 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc @@ -1,3 +1,71 @@ +struct resolved_native_grid_named_placement final { + bool has_row{false}; + bool has_column{false}; + size_t row{0}; + size_t column{0}; + size_t row_span{1}; + size_t column_span{1}; +}; + +static resolved_native_grid_named_placement resolve_native_grid_named_placement( + const node_style::grid_data& container, + const node_style::grid_data& placement) +{ + const auto find_area = [&](std::string_view name) + -> const node_style::grid_data::named_area* { + const auto first = name.find_first_not_of(" \t\r\n\f"); + if (first == std::string_view::npos) return nullptr; + name = name.substr(first, name.find_last_not_of(" \t\r\n\f") - first + 1U); + const auto found = std::find_if( + container.template_areas.begin(), container.template_areas.end(), + [&](const auto& area) { return area.name == name; }); + return found == container.template_areas.end() ? nullptr : &*found; + }; + resolved_native_grid_named_placement result; + if (placement.area_value.find('/') == std::string::npos) { + if (const auto* area = find_area(placement.area_value)) { + result.has_row = true; + result.has_column = true; + result.row = area->row_start; + result.column = area->column_start; + result.row_span = area->row_end - area->row_start; + result.column_span = area->column_end - area->column_start; + return result; + } + } + const auto find_generated_line_area = [&](std::string_view value, std::string_view suffix) + -> const node_style::grid_data::named_area* { + const auto first = value.find_first_not_of(" \t\r\n\f"); + if (first == std::string_view::npos) return nullptr; + value = value.substr(first, value.find_last_not_of(" \t\r\n\f") - first + 1U); + if (value.ends_with(suffix)) value.remove_suffix(suffix.size()); + return find_area(value); + }; + if (const auto* area = find_generated_line_area( + placement.row_start_value, "-start")) { + result.has_row = true; + result.row = area->row_start; + result.row_span = area->row_end - area->row_start; + if (const auto* end_area = find_generated_line_area( + placement.row_end_value, "-end")) { + if (end_area->row_end > result.row) result.row_span = end_area->row_end - result.row; + } + } + if (const auto* area = find_generated_line_area( + placement.column_start_value, "-start")) { + result.has_column = true; + result.column = area->column_start; + result.column_span = area->column_end - area->column_start; + if (const auto* end_area = find_generated_line_area( + placement.column_end_value, "-end")) { + if (end_area->column_end > result.column) { + result.column_span = end_area->column_end - result.column; + } + } + } + return result; +} + static size_t native_table_span(std::string_view text, size_t maximum = 1000, bool preserve_zero = false) { size_t cursor = text.find_first_not_of(" \t\n\r\f"); if (cursor == std::string_view::npos) return 1; @@ -1823,7 +1891,8 @@ void native_document::layout_children(dom_node& parent) && !parent_grid.auto_columns.empty(); if (is_grid_container(parent.style.display) && (parent_grid.two_columns || implicit_column_grid - || !parent_grid.template_columns.empty() || !parent_grid.template_rows.empty())) { + || !parent_grid.template_columns.empty() || !parent_grid.template_rows.empty() + || !parent_grid.template_areas.empty())) { using grid_track = node_style::grid_data::track; const auto distribute_fractions = [](std::vector& sizes, const std::vector& tracks, float available, float gaps) { @@ -1907,7 +1976,7 @@ void native_document::layout_children(dom_node& parent) ? parent.grid_layout().column_widths.size() : std::max( parent_grid.two_columns && effective_columns.empty() ? 2U : 1U, - effective_columns.size()); + std::max(effective_columns.size(), parent_grid.template_area_column_count)); const auto parse_line = [](std::string_view value) -> std::optional { const auto first = value.find_first_not_of(" \t\r\n"); if (first == std::string_view::npos) return std::nullopt; @@ -1983,21 +2052,29 @@ void native_document::layout_children(dom_node& parent) continue; } const auto& placement = child->style.grid(); + const auto named = resolve_native_grid_named_placement(parent_grid, placement); auto column_span = std::min( column_count, - placement.compiled_full_columns ? column_count : parse_span( + named.has_column ? named.column_span + : placement.compiled_full_columns ? column_count : parse_span( placement.column_start_value, placement.column_end_value, column_count)); - auto row_span = parse_span( + auto row_span = named.has_row ? named.row_span : parse_span( placement.row_start_value, placement.row_end_value, - std::max(parent_grid.template_rows.size(), 1U)); - const auto authored_column_start = placement.compiled_full_columns + std::max( + std::max(parent_grid.template_rows.size(), parent_grid.template_area_row_count), + 1U)); + const auto authored_column_start = named.has_column + ? std::optional{static_cast(named.column + 1U)} + : placement.compiled_full_columns ? std::optional{1} : parse_line(placement.column_start_value); const auto authored_column_end = placement.compiled_full_columns ? std::optional{-1} : parse_line(placement.column_end_value); - const auto authored_row = parse_line(placement.row_start_value); + const auto authored_row = named.has_row + ? std::optional{static_cast(named.row + 1U)} + : parse_line(placement.row_start_value); auto row = authored_row.has_value() && *authored_row > 0 ? static_cast(*authored_row - 1) : auto_row; auto column = authored_column_start.has_value() @@ -2029,7 +2106,12 @@ void native_document::layout_children(dom_node& parent) if (column + column_span > column_count) { column = column_count - column_span; } - while (!fits(row, column, row_span, column_span)) { + // Fully definite placements may overlap. Named template areas are + // definite in both axes, so a second item assigned to the same + // area must retain the area's geometry instead of being shifted + // into the next implicit row by the auto-placement cursor. + while (!(named.has_row && named.has_column) + && !fits(row, column, row_span, column_span)) { if (!authored_column_start.has_value() && !authored_column_end.has_value()) { ++column; @@ -2138,7 +2220,8 @@ void native_document::layout_children(dom_node& parent) } const auto row_count = std::max( - occupied.size(), parent_grid.template_rows.size()); + occupied.size(), + std::max(parent_grid.template_rows.size(), parent_grid.template_area_row_count)); std::vector row_heights(row_count, 0.0F); std::vector row_minimums(row_count, 0.0F); const auto contains_wrapping_flex = [&](const auto& self, @@ -4300,7 +4383,8 @@ float native_document::compute_intrinsic_size( const auto intrinsic_implicit_column_grid = node_grid.auto_flow_column && !node_grid.auto_columns.empty(); if (is_grid_container(node.style.display) - && (node_grid.two_columns || intrinsic_implicit_column_grid)) { + && (node_grid.two_columns || intrinsic_implicit_column_grid + || !node_grid.template_areas.empty())) { #if defined(WEBSCENE_NATIVE_ENGINE_INTRINSIC_SIZE_BRANCH_BENCHMARK) ++intrinsic_size_branch_counts_for_benchmark[7]; #endif @@ -4325,8 +4409,8 @@ float native_document::compute_intrinsic_size( } } const auto column_count = std::max( - intrinsic_implicit_column_grid ? 1U : 2U, - effective_columns.size()); + grid.two_columns ? 2U : 1U, + std::max(effective_columns.size(), grid.template_area_column_count)); struct intrinsic_grid_item final { const dom_node* node{nullptr}; size_t row{0}; @@ -4382,19 +4466,26 @@ float native_document::compute_intrinsic_size( || is_out_of_flow(child->style.position) || is_collapsible_whitespace_text(*child)) continue; const auto& placement = child->style.grid(); + const auto named = resolve_native_grid_named_placement(grid, placement); const auto column_span = std::min( column_count, - placement.compiled_full_columns ? column_count : span_for( + named.has_column ? named.column_span + : placement.compiled_full_columns ? column_count : span_for( placement.column_start_value, placement.column_end_value, column_count)); - const auto row_span = span_for( + const auto row_span = named.has_row ? named.row_span : span_for( placement.row_start_value, placement.row_end_value, - std::max(grid.template_rows.size(), 1U)); - const auto authored_column = placement.compiled_full_columns + std::max( + std::max(grid.template_rows.size(), grid.template_area_row_count), 1U)); + const auto authored_column = named.has_column + ? std::optional{static_cast(named.column + 1U)} + : placement.compiled_full_columns ? std::optional{1} : parse_line(placement.column_start_value); - const auto authored_row = parse_line(placement.row_start_value); + const auto authored_row = named.has_row + ? std::optional{static_cast(named.row + 1U)} + : parse_line(placement.row_start_value); auto row = authored_row.has_value() && *authored_row > 0 ? static_cast(*authored_row - 1) : auto_row; auto column = authored_column.has_value() && *authored_column > 0 @@ -4414,7 +4505,7 @@ float native_document::compute_intrinsic_size( } return true; }; - while (!fits(row, column)) { + while (!(named.has_row && named.has_column) && !fits(row, column)) { if (!authored_column.has_value()) { ++column; if (column + column_span > column_count) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc index 1425bf759..05087e2c4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -214,10 +214,12 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c result.grid_data_count = grid_allocations.size(); for (const auto* grid : grid_allocations) { result.grid_storage_bytes += - sizeof(node_style::grid_data) + 3U * sizeof(void*) + sizeof(node_style::grid_data) + 4U * sizeof(void*) + grid->template_columns.capacity() * sizeof(node_style::grid_data::track) + grid->template_rows.capacity() * sizeof(node_style::grid_data::track) + grid->auto_columns.capacity() * sizeof(node_style::grid_data::track) + + grid->template_areas.capacity() * sizeof(node_style::grid_data::named_area) + + grid->template_areas_value.capacity() + 1U + grid->area_value.capacity() + 1U + grid->row_value.capacity() + 1U + grid->row_start_value.capacity() + 1U @@ -225,6 +227,9 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + grid->column_value.capacity() + 1U + grid->column_start_value.capacity() + 1U + grid->column_end_value.capacity() + 1U; + for (const auto& area : grid->template_areas) { + result.grid_storage_bytes += area.name.capacity() + 1U; + } } result.textual_style_data_count = textual_allocations.size(); for (const auto* textual : textual_allocations) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 9a2d72b9f..95775b2d0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -751,7 +751,7 @@ struct v8_dom_runtime::implementation final { "borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius", "borderCollapse", "borderSpacing", "clear", "columnCount", "columns", "emptyCells", "fillOpacity", "float", - "gridArea", "gridColumn", "gridColumnEnd", "gridColumnStart", + "gridTemplateAreas", "gridArea", "gridColumn", "gridColumnEnd", "gridColumnStart", "gridRow", "gridRowEnd", "gridRowStart", "order", "orphans", "outlineColor", "outlineWidth", "outlineStyle", "overflow", "overflowX", "overflowY", "color", diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc index 223bba678..23c6aa08c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc @@ -838,6 +838,7 @@ || property == "min-width" || property == "max-width" || property == "min-height" || property == "max-height" || property == "grid-template-columns" || property == "grid-template-rows" + || property == "grid-template-areas" || property == "left" || property == "right" || property == "top" || property == "bottom"; }; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc index a05adec28..69944f07b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -14,12 +14,26 @@ auto& authored = node.mutable_authored_style(); authored.declarations.erase(canonical_name); authored.important_declarations.erase(canonical_name); - if (apply_grid_placement_declaration(node.style, name, "auto")) { + if (canonical_name == "grid-template-areas") { + css::apply_grid_template_areas(node.style, "none"); + constexpr std::array grid_properties{ + "grid-template-columns", "grid-template-rows", "grid-template-areas", + "grid-auto-columns", "grid-auto-flow", "grid-area", "grid-row", + "grid-row-start", "grid-row-end", "grid-column", "grid-column-start", + "grid-column-end"}; + const auto has_authored_grid = std::any_of( + grid_properties.begin(), grid_properties.end(), + [&](std::string_view property) { + return authored.declarations.contains(std::string(property)); + }); + if (!has_authored_grid) node.style.inline_property_mask &= ~inline_grid; + } else if (apply_grid_placement_declaration(node.style, name, "auto")) { // Removing a placement declaration restores its affected // longhands to their computed initial value. constexpr std::array grid_properties{ "grid-template-columns", "grid-template-rows", + "grid-template-areas", "grid-auto-columns", "grid-auto-flow", "grid-area", @@ -569,7 +583,10 @@ } authored.declarations[canonical_name] = specified_value; authored.important_declarations.erase(canonical_name); - if (apply_grid_placement_declaration(node->style, name, value)) { + if (canonical_name == "grid-template-areas") { + css::apply_grid_template_areas(node->style, value); + node->style.inline_property_mask |= inline_grid; + } else if (apply_grid_placement_declaration(node->style, name, value)) { // Placement CSSOM is stored as computed tokens; layout consumes the // existing numeric grid-column projection when applicable. node->style.inline_property_mask |= inline_grid; @@ -1081,6 +1098,8 @@ value = format_computed_length(node->style.flex_basis, node->layout.width); } else if (name == "flex-wrap") { value = flex_wrap_name(node->style); + } else if (name == "grid-template-areas") { + value = node->style.grid().template_areas_value; } else if (name == "grid-area") { value = node->style.grid().area_value; } else if (name == "grid-row") { @@ -1621,6 +1640,8 @@ value = format_corner_radius( node->style.border_bottom_left_radius, node->style.border_bottom_left_radius_y()); + } else if (name == "gridTemplateAreas") { + value = node->style.grid().template_areas_value; } else if (name == "gridArea") { value = node->style.grid().area_value; } else if (name == "gridRow") { @@ -1966,7 +1987,10 @@ } authored.declarations[canonical_name] = specified_value; authored.important_declarations.erase(canonical_name); - if (apply_grid_placement_declaration(node->style, name, value)) { + if (canonical_name == "grid-template-areas") { + css::apply_grid_template_areas(node->style, value); + node->style.inline_property_mask |= inline_grid; + } else if (apply_grid_placement_declaration(node->style, name, value)) { // Placement CSSOM is stored as computed tokens; layout consumes the // existing numeric grid-column projection when applicable. node->style.inline_property_mask |= inline_grid; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc index 1d511532e..e7eca5917 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc @@ -626,6 +626,9 @@ bool valid_cssom_declaration_value(std::string_view property_name, std::string_v const auto last = value.find_last_not_of(" \t\r\n\f\v"); const auto trimmed = value.substr(first, last - first + 1U); const auto canonical_name = canonical_css_property_name(property_name); + if (canonical_name == "grid-template-areas") { + return css::parse_grid_template_areas(trimmed).has_value(); + } auto normalized_value = std::string(trimmed); std::transform( normalized_value.begin(), normalized_value.end(), normalized_value.begin(), diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc index 31ad5eb4a..f7f5106e8 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc @@ -173,6 +173,69 @@ void test_compact_go_to_fixed_grid_tracks_preserve_trailing_space( "fullscreen Go To dialog lost its fixed tracks or trailing space: " + compact); } +void test_named_grid_template_areas_layout_cssom_and_mutation(webscene_engine* engine) +{ + resize(engine, 700, 400, 2U); + const auto result = evaluate(engine, R"JS( + (() => { + document.body.innerHTML = ` + +
+
`; + const rect = id => { + const value = document.getElementById(id).getBoundingClientRect(); + return [value.x,value.y,value.width,value.height].map(number => + Math.round(number * 1000) / 1000); + }; + const grid = document.getElementById('grid'); + const computed = getComputedStyle(grid).getPropertyValue('grid-template-areas'); + const beforeInvalid = grid.style.gridTemplateAreas; + grid.style.gridTemplateAreas = '"broken broken" "broken ."'; + const invalidIgnored = grid.style.gridTemplateAreas === beforeInvalid; + grid.style.gridTemplateAreas = + '". footer footer footer ." ". left . right ." ". header header header ."'; + const movedHeader = rect('header'); + grid.style.removeProperty('grid-template-areas'); + const restoredHeader = rect('header'); + + const first = '". a a a ." ". b . c ." ". d d d ."'; + const second = '". d d d ." ". b . c ." ". a a a ."'; + const started = performance.now(); + for (let index = 0; index < 500; index++) { + grid.style.gridTemplateAreas = index % 2 ? first : second; + } + const elapsed = performance.now() - started; + if (elapsed > 2000) throw new Error(`named-area mutation gate exceeded: ${elapsed}ms`); + grid.style.removeProperty('grid-template-areas'); + return { + computed, invalidIgnored, + header:rect('header'),headerPeer:rect('header-peer'),left:rect('left'),right:rect('right'),footer:rect('footer'), + movedHeader,restoredHeader + }; + })() + )JS", "native-grid-template-areas.js"); + require( + result == R"JSON({"computed":"\". header header header .\" \". left . right .\" \". footer footer footer .\"","invalidIgnored":true,"header":[46.667,0,606.667,80],"headerPeer":[46.667,0,606.667,80],"left":[46.667,80,280,280],"right":[373.333,80,280,280],"footer":[46.667,360,606.667,40],"movedHeader":[46.667,360,606.667,40],"restoredHeader":[46.667,0,606.667,80]})JSON", + "named grid template areas diverged in parsing, layout, CSSOM, or mutation: " + + result); +} + void test_media_query_list_tracks_outer_and_frame_viewport_breakpoints( webscene_engine* engine) { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 94577d795..4d4024480 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -235,6 +235,13 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "grid-template-areas") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, "focused grid engine creation failed"); + test_named_grid_template_areas_layout_cssom_and_mutation(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if (selected == "scrollbar-style-drag") { auto* focused_engine = webscene_engine_create(64); require(focused_engine != nullptr, "focused engine creation failed"); @@ -683,6 +690,7 @@ int main() test_modal_backdrop_scene(engine); test_responsive_positioned_sizing(engine); test_compact_go_to_fixed_grid_tracks_preserve_trailing_space(engine); + test_named_grid_template_areas_layout_cssom_and_mutation(engine); test_go_to_tab_lines_and_calendar_scroll_ranges(engine); test_media_query_list_tracks_outer_and_frame_viewport_breakpoints(engine); test_responsive_unset_restores_auto_inset(engine); diff --git a/src/WebScene.Css/CssArrangementEngine.cs b/src/WebScene.Css/CssArrangementEngine.cs index 1b548a860..6ebf8271c 100644 --- a/src/WebScene.Css/CssArrangementEngine.cs +++ b/src/WebScene.Css/CssArrangementEngine.cs @@ -1252,18 +1252,30 @@ private static void ArrangeFixedPixelGrid( IReadOnlyList tracks, IReadOnlyList fixedRowTracks) { - var placements = new List<(CssLayoutNode Node, int Row, int Column, bool Full, ResolvedMetrics Metrics)>(); + _ = CssGridTemplateAreas.TryParse(style.GridTemplateAreas, out var namedAreas); + var placements = new List<(CssLayoutNode Node, int Row, int Column, int RowSpan, int ColumnSpan, ResolvedMetrics Metrics)>(); var row = 0; var column = 0; foreach (var child in children) { + var named = default(CssGridNamedArea); + if (namedAreas is not null + && namedAreas.TryGetArea(child.Style.GridArea, out named) + && named.Column < tracks.Count) + { + placements.Add((child, named.Row, named.Column, named.RowSpan, + Math.Min(named.ColumnSpan, tracks.Count - named.Column), + ResolveMetrics(child, content.Size))); + continue; + } var full = tracks.Count == 2 && SpansBothColumns(child.Style.GridColumn); if (full && column != 0) { row++; column = 0; } - placements.Add((child, row, column, full, ResolveMetrics(child, content.Size))); + placements.Add((child, row, column, 1, full ? tracks.Count : 1, + ResolveMetrics(child, content.Size))); if (full) { row++; @@ -1276,7 +1288,7 @@ private static void ArrangeFixedPixelGrid( } } - var usedRowCount = placements.Count == 0 ? 0 : placements.Max(item => item.Row) + 1; + var usedRowCount = placements.Count == 0 ? 0 : placements.Max(item => item.Row + item.RowSpan); var rowHeights = new double[Math.Max(fixedRowTracks.Count, usedRowCount)]; for (var index = 0; index < fixedRowTracks.Count; index++) { @@ -1284,7 +1296,7 @@ private static void ArrangeFixedPixelGrid( } foreach (var item in placements) { - if (item.Row >= fixedRowTracks.Count) + if (item.RowSpan == 1 && item.Row >= fixedRowTracks.Count) { rowHeights[item.Row] = Math.Max( rowHeights[item.Row], @@ -1305,14 +1317,15 @@ private static void ArrangeFixedPixelGrid( } foreach (var item in placements) { - var trackWidth = item.Full - ? tracks.Sum() + columnGap * Math.Max(0, tracks.Count - 1) - : tracks[item.Column]; - var x = content.X + (item.Full ? 0 : columnOffsets[item.Column]) + item.Metrics.Margin.Left; + var trackWidth = tracks.Skip(item.Column).Take(item.ColumnSpan).Sum() + + columnGap * Math.Max(0, item.ColumnSpan - 1); + var trackHeight = rowHeights.Skip(item.Row).Take(item.RowSpan).Sum() + + rowGap * Math.Max(0, item.RowSpan - 1); + var x = content.X + columnOffsets[item.Column] + item.Metrics.Margin.Left; var y = content.Y + rowOffsets[item.Row] + item.Metrics.Margin.Top; var width = item.Metrics.OuterWidth ?? Math.Max(0, trackWidth - item.Metrics.Margin.Horizontal); var height = item.Metrics.OuterHeight - ?? Math.Max(0, rowHeights[item.Row] - item.Metrics.Margin.Vertical); + ?? Math.Max(0, trackHeight - item.Metrics.Margin.Vertical); ApplyRelativeOffset( item.Node.Style, content, diff --git a/src/WebScene.Css/CssComputedValueNormalizer.cs b/src/WebScene.Css/CssComputedValueNormalizer.cs index c6f449db8..7c3194a57 100644 --- a/src/WebScene.Css/CssComputedValueNormalizer.cs +++ b/src/WebScene.Css/CssComputedValueNormalizer.cs @@ -44,24 +44,36 @@ internal static bool TryExpandGridPlacementShorthand( if (normalizedName == "grid-area") { rowStart = components[0]; - columnStart = components.Count > 1 ? components[1] : "auto"; - rowEnd = components.Count > 2 ? components[2] : "auto"; - columnEnd = components.Count > 3 ? components[3] : "auto"; + var omitted = IsGridCustomIdentifier(rowStart) ? rowStart : "auto"; + columnStart = components.Count > 1 ? components[1] : omitted; + rowEnd = components.Count > 2 ? components[2] : omitted; + columnEnd = components.Count > 3 ? components[3] : omitted; return true; } if (normalizedName == "grid-row") { rowStart = components[0]; - rowEnd = components.Count > 1 ? components[1] : "auto"; + rowEnd = components.Count > 1 ? components[1] + : IsGridCustomIdentifier(rowStart) ? rowStart : "auto"; return true; } columnStart = components[0]; - columnEnd = components.Count > 1 ? components[1] : "auto"; + columnEnd = components.Count > 1 ? components[1] + : IsGridCustomIdentifier(columnStart) ? columnStart : "auto"; return true; } + private static bool IsGridCustomIdentifier(string value) + { + var token = value.Trim(); + if (token.Length == 0 || token is "auto" or "span" or "inherit" or "initial" + or "unset" or "revert" or "revert-layer") return false; + return !double.TryParse(token, NumberStyles.Number, CultureInfo.InvariantCulture, out _) + && !token.StartsWith("span ", StringComparison.OrdinalIgnoreCase); + } + internal static void ExpandShorthands(CssPropertyValueStore values) { ArgumentNullException.ThrowIfNull(values); diff --git a/src/WebScene.Css/CssGridTemplateAreas.cs b/src/WebScene.Css/CssGridTemplateAreas.cs new file mode 100644 index 000000000..96a9b252f --- /dev/null +++ b/src/WebScene.Css/CssGridTemplateAreas.cs @@ -0,0 +1,122 @@ +namespace WebScene.Css; + +public readonly record struct CssGridNamedArea( + string Name, + int Row, + int Column, + int RowSpan, + int ColumnSpan); + +/// +/// Parsed, rectangular named areas from a CSS grid-template-areas value. +/// +public sealed class CssGridTemplateAreas +{ + private readonly Dictionary _areas; + + private CssGridTemplateAreas( + int rowCount, + int columnCount, + string serialized, + Dictionary areas) + { + RowCount = rowCount; + ColumnCount = columnCount; + Serialized = serialized; + _areas = areas; + } + + public int RowCount { get; } + + public int ColumnCount { get; } + + public string Serialized { get; } + + public IReadOnlyCollection Areas => _areas.Values; + + public bool TryGetArea(string? name, out CssGridNamedArea area) + => _areas.TryGetValue(name?.Trim() ?? string.Empty, out area); + + public static bool TryParse(string? value, out CssGridTemplateAreas? template) + { + template = null; + var source = (value ?? string.Empty).AsSpan().Trim(); + if (source.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + template = new CssGridTemplateAreas(0, 0, "none", new(StringComparer.Ordinal)); + return true; + } + if (source.IsEmpty) return false; + + var rows = new List(); + var cursor = 0; + while (cursor < source.Length) + { + while (cursor < source.Length && char.IsWhiteSpace(source[cursor])) cursor++; + if (cursor == source.Length) break; + var quote = source[cursor++]; + if (quote is not ('\'' or '"')) return false; + var rowStart = cursor; + while (cursor < source.Length && source[cursor] != quote) + { + if (source[cursor] is '\n' or '\r' or '\f' or '\\') return false; + cursor++; + } + if (cursor == source.Length) return false; + var cells = source[rowStart..cursor].ToString() + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + cursor++; + if (cells.Length == 0 || rows.Count > 0 && cells.Length != rows[0].Length) return false; + for (var index = 0; index < cells.Length; index++) + { + var cell = cells[index]; + if (cell.All(static character => character == '.')) + { + cells[index] = "."; + continue; + } + if (!IsCustomIdentifier(cell)) return false; + } + rows.Add(cells); + } + if (rows.Count == 0) return false; + + var bounds = new Dictionary(StringComparer.Ordinal); + for (var row = 0; row < rows.Count; row++) + { + for (var column = 0; column < rows[row].Length; column++) + { + var name = rows[row][column]; + if (name == ".") continue; + if (!bounds.TryGetValue(name, out var area)) area = (row, column, row + 1, column + 1); + else area = (Math.Min(area.Top, row), Math.Min(area.Left, column), + Math.Max(area.Bottom, row + 1), Math.Max(area.Right, column + 1)); + bounds[name] = area; + } + } + + var areas = new Dictionary(StringComparer.Ordinal); + foreach (var (name, area) in bounds) + { + for (var row = area.Top; row < area.Bottom; row++) + for (var column = area.Left; column < area.Right; column++) + { + if (rows[row][column] != name) return false; + } + areas[name] = new CssGridNamedArea( + name, area.Top, area.Left, area.Bottom - area.Top, area.Right - area.Left); + } + var serialized = string.Join(' ', rows.Select(static row => $"\"{string.Join(' ', row)}\"")); + template = new CssGridTemplateAreas(rows.Count, rows[0].Length, serialized, areas); + return true; + } + + private static bool IsCustomIdentifier(string value) + { + if (value is "auto" or "span" or "initial" or "inherit" or "unset" or "revert" + or "revert-layer" or "default") return false; + if (value.Length == 0 || !(char.IsLetter(value[0]) || value[0] is '_' or '-')) return false; + return value.Skip(1).All(static character => char.IsLetterOrDigit(character) + || character is '_' or '-'); + } +} diff --git a/src/WebScene.Css/CssLayoutModel.cs b/src/WebScene.Css/CssLayoutModel.cs index 149ade215..6f52ebda4 100644 --- a/src/WebScene.Css/CssLayoutModel.cs +++ b/src/WebScene.Css/CssLayoutModel.cs @@ -253,6 +253,10 @@ public sealed record CssLayoutStyle public string GridTemplateRows { get; init; } = string.Empty; + public string GridTemplateAreas { get; init; } = string.Empty; + + public string GridArea { get; init; } = string.Empty; + public string GridColumn { get; init; } = string.Empty; public double FlexGrow { get; init; } diff --git a/src/WebScene.Css/CssMeasurementEngine.cs b/src/WebScene.Css/CssMeasurementEngine.cs index 01a01a033..bfb5feba2 100644 --- a/src/WebScene.Css/CssMeasurementEngine.cs +++ b/src/WebScene.Css/CssMeasurementEngine.cs @@ -698,6 +698,7 @@ private static WebSceneSize MeasureFixedPixelGrid( var columnGap = Math.Max(0, ResolveForMeasure(root.Style.ColumnGap, availableWidth) ?? 0); var rowGap = Math.Max(0, ResolveForMeasure(root.Style.RowGap, availableHeight) ?? 0); var rows = fixedRowTracks.ToList(); + _ = CssGridTemplateAreas.TryParse(root.Style.GridTemplateAreas, out var namedAreas); var row = 0; var column = 0; foreach (var child in root.Children.Where(static child => @@ -705,18 +706,30 @@ private static WebSceneSize MeasureFixedPixelGrid( && !IsNonRenderedTableTrack(child.Style.Display) && child.Style.Position is not (CssLayoutPosition.Absolute or CssLayoutPosition.Fixed))) { - var full = tracks.Count == 2 && SpansBothColumns(child.Style.GridColumn); + var named = default(CssGridNamedArea); + var hasNamedArea = namedAreas is not null + && namedAreas.TryGetArea(child.Style.GridArea, out named) + && named.Column < tracks.Count; + var full = !hasNamedArea && tracks.Count == 2 && SpansBothColumns(child.Style.GridColumn); if (full && column != 0) { row++; column = 0; } + var itemRow = hasNamedArea ? named.Row : row; + var itemColumn = hasNamedArea ? named.Column : column; + var rowSpan = hasNamedArea ? named.RowSpan : 1; + var columnSpan = hasNamedArea + ? Math.Min(named.ColumnSpan, tracks.Count - named.Column) + : full ? tracks.Count : 1; var metrics = ResolveChrome(child.Style, availableWidth, availableHeight); - var trackWidth = full - ? tracks.Sum() + columnGap * Math.Max(0, tracks.Count - 1) - : tracks[column]; + var trackWidth = tracks.Skip(itemColumn).Take(columnSpan).Sum() + + columnGap * Math.Max(0, columnSpan - 1); var declaredWidth = ResolveForMeasure(child.Style.Width, trackWidth); - var rowTrackHeight = row < fixedRowTracks.Count ? fixedRowTracks[row] : (double?)null; + var rowTrackHeight = itemRow + rowSpan <= fixedRowTracks.Count + ? fixedRowTracks.Skip(itemRow).Take(rowSpan).Sum() + + rowGap * Math.Max(0, rowSpan - 1) + : (double?)null; var declaredHeight = ResolveForMeasure(child.Style.Height, rowTrackHeight ?? availableHeight); var childWidth = declaredWidth.HasValue ? ToOuter(declaredWidth, metrics.HorizontalChrome, child.Style.BoxSizing) ?? trackWidth @@ -733,10 +746,14 @@ private static WebSceneSize MeasureFixedPixelGrid( metrics.VerticalChrome, child.Style.BoxSizing) ?? measured.Height) + metrics.Margin.Vertical; - while (rows.Count <= row) rows.Add(0); - if (row >= fixedRowTracks.Count) + while (rows.Count < itemRow + rowSpan) rows.Add(0); + if (rowSpan == 1 && itemRow >= fixedRowTracks.Count) + { + rows[itemRow] = Math.Max(rows[itemRow], itemHeight); + } + if (hasNamedArea) { - rows[row] = Math.Max(rows[row], itemHeight); + continue; } if (full) { diff --git a/src/WebScene.Css/CssMutationInvalidationPlanner.cs b/src/WebScene.Css/CssMutationInvalidationPlanner.cs index e4149f927..007f4cbab 100644 --- a/src/WebScene.Css/CssMutationInvalidationPlanner.cs +++ b/src/WebScene.Css/CssMutationInvalidationPlanner.cs @@ -36,7 +36,8 @@ public static class CssMutationInvalidationPlanner "display", "position", "top", "right", "bottom", "left", "inset", "width", "height", "min-width", "min-height", "max-width", "max-height", "margin", "padding", "overflow", "box-sizing", "flex", "flex-basis", "flex-direction", "flex-flow", "flex-grow", "flex-shrink", "flex-wrap", "grid", - "grid-template-columns", "grid-template-rows", "grid-column", "grid-column-start", "grid-column-end", + "grid-template-columns", "grid-template-rows", "grid-template-areas", "grid-area", "grid-row", + "grid-row-start", "grid-row-end", "grid-column", "grid-column-start", "grid-column-end", "align-content", "align-items", "align-self", "justify-content", "gap", "list-style", "list-style-position", "list-style-type", "order", "row-gap", "column-gap", "z-index", "white-space" }; diff --git a/src/WebScene.Css/CssPropertyStorage.cs b/src/WebScene.Css/CssPropertyStorage.cs index 86a12251e..78358e71c 100644 --- a/src/WebScene.Css/CssPropertyStorage.cs +++ b/src/WebScene.Css/CssPropertyStorage.cs @@ -31,7 +31,8 @@ internal static class CssKnownProperties "padding-right", "padding-top", "pointer-events", "position", "right", "row-gap", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "text-align", "text-indent", "text-transform", "top", "transform", "visibility", "white-space", "width", "word-spacing", "z-index", - "outline", "outline-color", "outline-offset", "outline-style", "outline-width" + "outline", "outline-color", "outline-offset", "outline-style", "outline-width", + "grid-template-areas" ]; private static readonly FrozenDictionary s_ids = Names @@ -73,6 +74,7 @@ internal static bool TryGetId(string name, out int id) "transform" => 93, "visibility" => 94, "white-space" => 95, "width" => 96, "word-spacing" => 97, "z-index" => 98, "outline" => 99, "outline-color" => 100, "outline-offset" => 101, "outline-style" => 102, "outline-width" => 103, + "grid-template-areas" => 104, _ => -1 }; diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index 8e1eb45e5..7ecc7d848 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -227,11 +227,11 @@ }, { "family": "grid-layout", - "required": ["fixed pixel columns", "fixed pixel rows", "row and column gaps", "source-order auto placement", "collapsible authored whitespace", "grid-area placement expansion", "grid-row and grid-column placement expansion", "unitless integer grid lines", "shorthand/longhand cascade precedence", "placement removal to auto"], + "required": ["fixed pixel columns", "fixed pixel rows", "row and column gaps", "source-order auto placement", "collapsible authored whitespace", "grid-area placement expansion", "grid-row and grid-column placement expansion", "unitless integer grid lines", "shorthand/longhand cascade precedence", "placement removal to auto", "rectangular named template areas", "sparse dot cells", "generated area line placement", "atomic area-template validation", "area-template mutation invalidation"], "candidate": ["fractional columns and rows", "automatic tracks", "column auto-flow", "equal implicit fractional tracks", "minmax tracks with fixed or fractional maxima", "ordinary numeric explicit placement", "start-side span placement", "row and column spans", "column subgrid with inherited used tracks", "display:contents grid item flattening", "post-track wrapped block contributions to automatic rows"], - "productEvidence": ["calendar date/time controls", "calendar seven-column date matrix", "Go to dialog", "reported trading ticket tab strip", "TradingView symbol-search result list", "TradingView compact settings property tables"], - "coverage": ["css/css-grid/alignment/grid-gutters-001.html", "contracts/css-grid-placement-cssom.html", "contracts/css-grid-form-layout.html", "contracts/css-grid-column-subgrid-form-row.html", "contracts/css-grid-display-contents-items.html", "contracts/implicit-grid-and-compact-flex-controls.html", "contracts/responsive-settings-property-grid.html", "css/css-grid/parsing/grid-area-computed.html", "css/css-grid/parsing/grid-area-valid.html", "CssFixedGridRowTracksTests", "CssLayoutResizeSpikeTests", "CssComputedValueNormalizerTests", "test_tradingview_settings_subgrid_keeps_controls_on_their_rows", "test_tradingview_symbol_search_display_contents_rows_join_parent_grid", "test_tradingview_compact_property_table_expands_wrapped_grid_rows", "artifacts/web-platform-grid-placement-{chrome,managed,native}-v1-20260723"], - "gap": "The required lane still claims only the reviewed fixed-track and placement CSSOM slice. Candidate coverage includes the product-neutral form compositions reported by the 7GUIs port, the column subgrid used by paired Trading settings controls, display:contents grid item flattening used by symbol-search results, column auto-flow, equal implicit tracks, start-side span placement, and post-track wrapped-content row growth for the compact 1fr/min-content composition. Grid state and parsed tracks remain behind optional records. Row subgrid, named subgrid lines, repeat(), dense backfill, area templates, general content-based spanning-track growth beyond this bounded non-spanning case, full alignment semantics, and the broader Grid specification remain outside this claim. Evidence: artifacts/7guis-css-grid-form-layout-fixed/results.json and artifacts/7guis-required-regression/results.json." + "productEvidence": ["calendar date/time controls", "calendar seven-column date matrix", "Go to dialog", "reported trading ticket tab strip", "TradingView symbol-search result list", "TradingView compact settings property tables", "Code OSS Welcome layout"], + "coverage": ["css/css-grid/alignment/grid-gutters-001.html", "contracts/css-grid-placement-cssom.html", "contracts/css-grid-template-areas.html", "contracts/css-grid-form-layout.html", "contracts/css-grid-column-subgrid-form-row.html", "contracts/css-grid-display-contents-items.html", "contracts/implicit-grid-and-compact-flex-controls.html", "contracts/responsive-settings-property-grid.html", "css/css-grid/parsing/grid-area-computed.html", "css/css-grid/parsing/grid-area-valid.html", "CssGridTemplateAreasTests", "CssFixedGridRowTracksTests", "CssLayoutResizeSpikeTests", "CssComputedValueNormalizerTests", "test_named_grid_template_areas_layout_cssom_and_mutation", "test_tradingview_settings_subgrid_keeps_controls_on_their_rows", "test_tradingview_symbol_search_display_contents_rows_join_parent_grid", "test_tradingview_compact_property_table_expands_wrapped_grid_rows", "artifacts/web-platform-grid-placement-{chrome,managed,native}-v1-20260723"], + "gap": "The required lane claims the reviewed fixed-track and placement CSSOM slice plus rectangular named template areas, sparse dot cells, area-generated line names, atomic invalid-template rejection, and synchronous mutation. Candidate coverage includes the product-neutral form compositions reported by the 7GUIs port, the column subgrid used by paired Trading settings controls, display:contents grid item flattening used by symbol-search results, column auto-flow, equal implicit tracks, start-side span placement, and post-track wrapped-content row growth for the compact 1fr/min-content composition. Grid state and parsed tracks remain behind optional records. Row subgrid, arbitrary authored named lines, repeat(), dense backfill, nonrectangular areas, general content-based spanning-track growth beyond this bounded non-spanning case, full alignment semantics, and the broader Grid specification remain outside this claim. Evidence: artifacts/7guis-css-grid-form-layout-fixed/results.json and artifacts/7guis-required-regression/results.json." }, { "family": "collapsed-single-select", diff --git a/tests/WebPlatformSubset/contracts/css-grid-template-areas.html b/tests/WebPlatformSubset/contracts/css-grid-template-areas.html new file mode 100644 index 000000000..e980b32db --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-grid-template-areas.html @@ -0,0 +1,100 @@ + + +CSS Grid named template areas parsing, layout, and mutation + + +
+
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index dca0c4910..a43fa59c4 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -512,6 +512,13 @@ "evidence": ["jquery-4.0.0-css-grid-unitless-upstream-source", "wpt-css-grid-parsing-grid-area-computed"], "reason": "Required product-neutral reduction of a shared native jQuery CSS Grid placement failure. It covers the bounded integer/auto slice of grid-area, grid-row, grid-column, and their four placement longhands, including stylesheet cascade, longhand precedence, CSSOM removal, and synchronous computed serialization. Chrome and native pass 6/6 assertions. Upstream authority: css/css-grid/parsing/grid-area-computed.html and grid-area-valid.html. Evidence: artifacts/web-platform-grid-placement-{chrome,managed,native}-v1-20260723/results.json." }, + { + "path": "contracts/css-grid-template-areas.html", + "type": "contract", + "capabilities": ["grid-template-areas", "named-grid-area-placement", "sparse-grid-areas", "template-area-validation", "template-area-mutation-invalidation"], + "evidence": ["code-oss-welcome-five-column-layout", "webscene-issue-98"], + "reason": "Product-neutral reduction of the named-area composition used by Code OSS Welcome. It covers normalized CSSOM serialization, sparse rectangular areas across fractional and minmax tracks, atomic rejection of nonrectangular or unequal rows, synchronous mutation/removal layout, and a bounded 500-mutation gate." + }, { "path": "css/CSS2/generated-content/before-content-display-002.xht", "type": "reftest", diff --git a/tests/WebScene.Css.Tests/CssArrangementEngineTests.cs b/tests/WebScene.Css.Tests/CssArrangementEngineTests.cs index 66f4d3fa3..6be3d0fae 100644 --- a/tests/WebScene.Css.Tests/CssArrangementEngineTests.cs +++ b/tests/WebScene.Css.Tests/CssArrangementEngineTests.cs @@ -379,6 +379,31 @@ public void FixedPixelGridTracksStretchAutoChildrenAndIncludeColumnGap() Assert.Equal(new WebSceneRect(162, 0, 100, 28), snapshot[3].BorderBox); } + [Fact] + public void FixedPixelGridPlacesNamedAreasAcrossSparseTracks() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + GridTemplateColumns = "20px 40px 30px", + GridTemplateRows = "10px 20px 15px", + GridTemplateAreas = "\"header header .\" \"left . right\" \"footer footer footer\"", + ColumnGap = CssLayoutLength.Pixels(2), + RowGap = CssLayoutLength.Pixels(2) + }); + root.Add(new CssLayoutNode(2, new CssLayoutStyle { GridArea = "header" })); + root.Add(new CssLayoutNode(3, new CssLayoutStyle { GridArea = "left" })); + root.Add(new CssLayoutNode(4, new CssLayoutStyle { GridArea = "right" })); + root.Add(new CssLayoutNode(5, new CssLayoutStyle { GridArea = "footer" })); + + var snapshot = new CssArrangementEngine().Arrange(root, new WebSceneSize(94, 49)); + + Assert.Equal(new WebSceneRect(0, 0, 62, 10), snapshot[2].BorderBox); + Assert.Equal(new WebSceneRect(0, 12, 20, 20), snapshot[3].BorderBox); + Assert.Equal(new WebSceneRect(64, 12, 30, 20), snapshot[4].BorderBox); + Assert.Equal(new WebSceneRect(0, 34, 94, 15), snapshot[5].BorderBox); + } + [Theory] [InlineData(CssLayoutJustifyContent.FlexEnd, 60)] [InlineData(CssLayoutJustifyContent.Center, 30)] diff --git a/tests/WebScene.Css.Tests/CssComputedValueNormalizerTests.cs b/tests/WebScene.Css.Tests/CssComputedValueNormalizerTests.cs index a5c7640f3..7b4d1ea7a 100644 --- a/tests/WebScene.Css.Tests/CssComputedValueNormalizerTests.cs +++ b/tests/WebScene.Css.Tests/CssComputedValueNormalizerTests.cs @@ -47,6 +47,7 @@ public void ExpandsLegacyGridGapAliasIntoModernLonghands() [Theory] [InlineData("2", "2", "auto", "auto", "auto")] + [InlineData("header", "header", "header", "header", "header")] [InlineData("2 / 3", "2", "3", "auto", "auto")] [InlineData("2 / 3 / 4 / 5", "2", "3", "4", "5")] public void ExpandsGridAreaPlacementComponents( diff --git a/tests/WebScene.Css.Tests/CssGridTemplateAreasTests.cs b/tests/WebScene.Css.Tests/CssGridTemplateAreasTests.cs new file mode 100644 index 000000000..4eb8511e9 --- /dev/null +++ b/tests/WebScene.Css.Tests/CssGridTemplateAreasTests.cs @@ -0,0 +1,44 @@ +using WebScene.Css; +using Xunit; + +namespace WebScene.Css.Tests; + +public sealed class CssGridTemplateAreasTests +{ + [Fact] + public void ParsesSparseRectangularNamedAreas() + { + Assert.True(CssGridTemplateAreas.TryParse( + "'. header header header .' '. left . right .' '. footer footer footer .'", + out var template)); + + Assert.NotNull(template); + Assert.Equal(3, template.RowCount); + Assert.Equal(5, template.ColumnCount); + Assert.Equal("\". header header header .\" \". left . right .\" \". footer footer footer .\"", + template.Serialized); + Assert.True(template.TryGetArea("header", out var header)); + Assert.Equal(new CssGridNamedArea("header", 0, 1, 1, 3), header); + Assert.True(template.TryGetArea("right", out var right)); + Assert.Equal(new CssGridNamedArea("right", 1, 3, 1, 1), right); + } + + [Theory] + [InlineData("\"a a\" \"a .\"")] + [InlineData("\"a a\" \"a\"")] + [InlineData("\"a .\" \". a\"")] + [InlineData("\"auto\"")] + public void RejectsNonRectangularOrMalformedTemplates(string value) + { + Assert.False(CssGridTemplateAreas.TryParse(value, out _)); + } + + [Fact] + public void ParsesNoneAsAnEmptyTemplate() + { + Assert.True(CssGridTemplateAreas.TryParse("none", out var template)); + Assert.NotNull(template); + Assert.Empty(template.Areas); + Assert.Equal("none", template.Serialized); + } +} diff --git a/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs b/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs index 667c80265..6773179ab 100644 --- a/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs +++ b/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs @@ -256,6 +256,34 @@ public void FixedPixelGridTracksConstrainAutoChildrenAndIncludeColumnGap() Assert.Equal(100, measurer.Constraints[3].Width); } + [Fact] + public void FixedPixelGridMeasuresNamedAreaSpans() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + GridTemplateColumns = "20px 40px 30px", + GridTemplateRows = "10px 20px 15px", + GridTemplateAreas = "\"header header .\" \"left . right\" \"footer footer footer\"", + ColumnGap = CssLayoutLength.Pixels(2), + RowGap = CssLayoutLength.Pixels(2) + }); + root.Add(new CssLayoutNode(2, new CssLayoutStyle { GridArea = "header" })); + root.Add(new CssLayoutNode(3, new CssLayoutStyle { GridArea = "left" })); + root.Add(new CssLayoutNode(4, new CssLayoutStyle { GridArea = "right" })); + root.Add(new CssLayoutNode(5, new CssLayoutStyle { GridArea = "footer" })); + var measurer = new RecordingMeasurer( + (2, WebSceneSize.Empty), (3, WebSceneSize.Empty), + (4, WebSceneSize.Empty), (5, WebSceneSize.Empty)); + + var desired = new CssMeasurementEngine().Measure( + root, new WebSceneSize(94, 49), measurer); + + Assert.Equal(new WebSceneSize(94, 49), desired); + Assert.Equal(62, measurer.Constraints[2].Width); + Assert.Equal(94, measurer.Constraints[5].Width); + } + [Fact] public void TableMeasurementSharesIntrinsicColumnWidthsAcrossRows() {