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 6c6cea7ab..68211c205 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_finalization.h @@ -55,6 +55,7 @@ inline bool grid_layout_equal( if (!layout_length_equal(left_track.minimum, right_track.minimum) || !layout_length_equal(left_track.maximum, right_track.maximum) || left_track.fraction != right_track.fraction + || left_track.maximum_is_auto != right_track.maximum_is_auto || left_track.kind != right_track.kind) { return false; } @@ -132,6 +133,7 @@ inline bool computed_layout_style_equal( && left.direction == right.direction && left.align_items == right.align_items && left.align_self == right.align_self + && left.align_content_stretches == right.align_content_stretches && left.justify_content == right.justify_content && left.overflow_x == right.overflow_x && left.overflow_y == right.overflow_y diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h index ad76f021f..2ecb266a4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h @@ -129,6 +129,9 @@ inline void reset_cascaded_style(dom_node& node, node.style.align_self = align_mode::stretch; node.style.align_self_specified = false; } + if ((node.style.inline_property_mask & inline_align_content) == 0U) { + node.style.align_content_stretches = true; + } if ((node.style.inline_property_mask & inline_justify_content) == 0U) { node.style.justify_content = justify_mode::start; } 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 076c7ede1..9ee0766ae 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_layout_values.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_layout_values.h @@ -71,6 +71,7 @@ inline std::vector parse_simple_grid_tracks( if (token == "auto" || token == "max-content") { grid_track track; track.kind = grid_track::sizing::automatic; + track.maximum_is_auto = token == "auto"; return track; } if (token == "min-content") { @@ -106,6 +107,7 @@ inline std::vector parse_simple_grid_tracks( track.minimum = native_document::parse_length(std::string(minimum)); } track.fraction = parse_fraction(maximum); + track.maximum_is_auto = maximum == "auto"; if (track.fraction <= 0 && maximum != "auto" && maximum != "min-content" && maximum != "max-content") { track.maximum = native_document::parse_length(std::string(maximum)); @@ -498,6 +500,8 @@ bool apply_flex_value(dom_node& node,const std::string& name,const std::string& : value == "flex-end" || value == "end" ? align_mode::end : value == "baseline" || value == "first baseline" ? align_mode::baseline : align_mode::stretch; + } else if (name == "align-content" && !is_inline(inline_align_content)) { + node.style.align_content_stretches = value == "normal" || value == "stretch"; } else if (name == "justify-content" && !is_inline(inline_justify_content)) { node.style.justify_content = value == "center" ? justify_mode::center : value == "flex-end" || value == "end" ? justify_mode::end 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 aece06914..50a850b7c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h @@ -66,6 +66,7 @@ enum inline_style_property : uint64_t { inline_scrollbar_width = 1ULL << 58U, inline_scrollbar_color = 1ULL << 59U, inline_svg_stroke_width = 1ULL << 60U, + inline_align_content = 1ULL << 61U, inline_transition = inline_transition_property | inline_transition_duration | inline_transition_delay | inline_transition_timing }; @@ -152,6 +153,7 @@ inline uint64_t property_mask(std::string_view name) } if (name == "align-items") return inline_align_items; if (name == "align-self") return inline_align_self; + if (name == "align-content" || name == "alignContent") return inline_align_content; if (name == "justify-content") return inline_justify_content; if (name == "gap" || name == "row-gap" || name == "column-gap" || name == "rowGap" || name == "columnGap") return inline_gap; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index 69d2bad87..427f8495a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -86,6 +86,9 @@ inline void apply_all_unset(dom_node& node) reset.align_self = previous.align_self; reset.align_self_specified = previous.align_self_specified; } + if (is_inline(inline_align_content)) { + reset.align_content_stretches = previous.align_content_stretches; + } if (is_inline(inline_justify_content)) { reset.justify_content = previous.justify_content; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 7bf6209aa..fe2c45dd1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -307,6 +307,7 @@ struct node_style final { css_length maximum{}; float fraction{0}; sizing kind{sizing::automatic}; + bool maximum_is_auto{false}; }; struct named_area final { @@ -732,6 +733,7 @@ struct node_style final { align_mode align_items{align_mode::stretch}; align_mode align_self{align_mode::stretch}; justify_mode justify_content{justify_mode::start}; + bool align_content_stretches : 1 {true}; overflow_mode overflow_x{overflow_mode::visible}; overflow_mode overflow_y{overflow_mode::visible}; bool outline_current_color : 1 {false}; 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 7138fb210..4bd9033ef 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc @@ -2382,7 +2382,8 @@ void native_document::layout_children(dom_node& parent) } const auto single_auto_row = row_count == 1U && (parent_grid.template_rows.empty() - || parent_grid.template_rows[0].kind == grid_track::sizing::automatic); + || (parent_grid.template_rows[0].kind == grid_track::sizing::automatic + && parent_grid.template_rows[0].maximum_is_auto)); if (single_auto_row && parent.used_height_is_definite) { // An auto track's max-content growth limit is not its minimum. // Scrollable items and explicit min-height can allow the row to @@ -2395,18 +2396,18 @@ void native_document::layout_children(dom_node& parent) row_heights.begin(), row_heights.end(), total_row_gap); if (fractional_row_weight > 0) { distribute_fractions(row_heights,parent_grid.template_rows,content.height,total_row_gap); - } else if (single_auto_row + } else if (parent.used_height_is_definite + && parent.style.align_content_stretches && committed_height < content.height) { - // The common one-row implicit grid case stretches its auto row to - // the definite container height. Keep this compatibility path - // scoped until multi-row align-content distribution is modeled; - // distributing every multi-row auto grid here can rewrite an - // application's primary layout when it has other alignment rules. + // `align-content: normal` behaves as stretch for grid containers. + // Distribute remaining block space only to tracks whose maximum + // sizing function is auto. In particular, minmax(min-content, + // auto) grows while fixed, percentage, max-content, and flexible + // tracks keep their resolved sizes. std::vector stretchable_rows; for (size_t row = 0; row < row_count; ++row) { if (row >= parent_grid.template_rows.size() - || parent_grid.template_rows[row].kind - == grid_track::sizing::automatic) { + || parent_grid.template_rows[row].maximum_is_auto) { stretchable_rows.push_back(row); } } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 53200d97b..57b1f8355 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -743,7 +743,7 @@ struct v8_dom_runtime::implementation final { "left", "top", "right", "bottom", "inset", "insetInlineStart", "insetInlineEnd", "display", "position", "contain", "cssFloat", "flexDirection", "flexFlow", "flexGrow", "flexShrink", "flexBasis", "flexWrap", - "alignItems", "alignSelf", "justifyContent", "gap", "rowGap", "columnGap", + "alignItems", "alignSelf", "alignContent", "justifyContent", "gap", "rowGap", "columnGap", "gridGap", "gridRowGap", "gridColumnGap", "padding", "paddingInline", "paddingBlock", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", 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 69944f07b..ccfd4d43d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -140,6 +140,9 @@ node.style.align_self = align_mode::stretch; node.style.align_self_specified = false; node.style.inline_property_mask &= ~inline_align_self; + } else if (name == "alignContent" || name == "align-content") { + node.style.align_content_stretches = true; + node.style.inline_property_mask &= ~inline_align_content; } else if (name == "justifyContent" || name == "justify-content") { node.style.justify_content = justify_mode::start; node.style.inline_property_mask &= ~inline_justify_content; @@ -708,6 +711,9 @@ : value == "baseline" || value == "first baseline" ? align_mode::baseline : align_mode::stretch; node->style.inline_property_mask |= inline_align_self; + } else if (name == "align-content") { + node->style.align_content_stretches = value == "normal" || value == "stretch"; + node->style.inline_property_mask |= inline_align_content; } else if (name == "justify-content") { node->style.justify_content = value == "center" ? justify_mode::center : value == "flex-end" || value == "end" ? justify_mode::end @@ -1553,6 +1559,8 @@ : node->style.align_self == align_mode::start ? "flex-start" : node->style.align_self == align_mode::end ? "flex-end" : node->style.align_self == align_mode::baseline ? "baseline" : "stretch"; + } else if (name == "alignContent") { + value = node->style.align_content_stretches ? "stretch" : "flex-start"; } else if (name == "justifyContent") { value = node->style.justify_content == justify_mode::center ? "center" : node->style.justify_content == justify_mode::end ? "flex-end" @@ -2097,6 +2105,9 @@ : value == "baseline" || value == "first baseline" ? align_mode::baseline : align_mode::stretch; node->style.inline_property_mask |= inline_align_self; + } else if (name == "alignContent") { + node->style.align_content_stretches = value == "normal" || value == "stretch"; + node->style.inline_property_mask |= inline_align_content; } else if (name == "justifyContent") { node->style.justify_content = value == "center" ? justify_mode::center : value == "flex-end" || value == "end" ? justify_mode::end 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 f7f5106e8..ea2db8471 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 @@ -236,6 +236,63 @@ void test_named_grid_template_areas_layout_cssom_and_mutation(webscene_engine* e + result); } +void test_grid_auto_maximum_tracks_stretch_remaining_space(webscene_engine* engine) +{ + resize(engine, 700, 400, 202U); + 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 stretched = rect('footer'); + grid.style.alignContent = 'flex-start'; + const flexStart = rect('footer'); + grid.style.removeProperty('align-content'); + const restored = rect('footer'); + grid.className = 'nonstretch'; + const stylesheet = rect('footer'); + grid.style.alignContent = 'stretch'; + const inlineOverride = rect('footer'); + grid.style.removeProperty('align-content'); + const cascadeRestored = rect('footer'); + grid.className = ''; + + const started = performance.now(); + let publicationChecksum = 0; + for (let index = 0; index < 500; index++) { + grid.style.height = index % 2 ? '400px' : '500px'; + publicationChecksum += rect('footer')[1]; + } + const elapsed = performance.now() - started; + if (elapsed > 2000) throw new Error(`auto-max publication gate exceeded: ${elapsed}ms`); + grid.style.height = '400px'; + return {stretched,flexStart,restored,stylesheet,inlineOverride,cascadeRestored, + final:rect('footer'),publicationChecksumPositive:publicationChecksum>0}; + })() + )JS", "native-grid-auto-max-stretch.js"); + require( + result == R"JSON({"stretched":[0,382,700,18],"flexStart":[0,300,700,18],"restored":[0,382,700,18],"stylesheet":[0,300,700,18],"inlineOverride":[0,382,700,18],"cascadeRestored":[0,300,700,18],"final":[0,382,700,18],"publicationChecksumPositive":true})JSON", + "auto-maximum grid row stretch or mutation diverged: " + 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 f18695cd8..ae3505144 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -262,6 +262,13 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "grid-auto-max-stretch") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, "focused grid engine creation failed"); + test_grid_auto_maximum_tracks_stretch_remaining_space(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"); @@ -714,6 +721,7 @@ int main() 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_grid_auto_maximum_tracks_stretch_remaining_space(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 6ebf8271c..4219b42f3 100644 --- a/src/WebScene.Css/CssArrangementEngine.cs +++ b/src/WebScene.Css/CssArrangementEngine.cs @@ -1214,8 +1214,10 @@ private static void ArrangeGrid( { if (TryParseFixedPixelTracks(style.GridTemplateColumns, out var fixedTracks)) { - _ = TryParseFixedPixelTracks(style.GridTemplateRows, out var fixedRowTracks); - ArrangeFixedPixelGrid(children, style, content, boxes, fixedTracks, fixedRowTracks); + var parsedRows = CssGridTrackList.TryParseRows( + style.GridTemplateRows, content.Height, out var rowTracks); + ArrangeFixedPixelGrid(children, style, content, boxes, fixedTracks, rowTracks, + parsedRows || string.IsNullOrWhiteSpace(style.GridTemplateRows)); return; } @@ -1250,7 +1252,8 @@ private static void ArrangeFixedPixelGrid( WebSceneRect content, Dictionary boxes, IReadOnlyList tracks, - IReadOnlyList fixedRowTracks) + IReadOnlyList rowTracks, + bool rowTemplateSupportsDistribution) { _ = CssGridTemplateAreas.TryParse(style.GridTemplateAreas, out var namedAreas); var placements = new List<(CssLayoutNode Node, int Row, int Column, int RowSpan, int ColumnSpan, ResolvedMetrics Metrics)>(); @@ -1289,14 +1292,15 @@ private static void ArrangeFixedPixelGrid( } 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++) + var rowHeights = new double[Math.Max(rowTracks.Count, usedRowCount)]; + for (var index = 0; index < rowTracks.Count; index++) { - rowHeights[index] = fixedRowTracks[index]; + rowHeights[index] = rowTracks[index].BaseSize; } foreach (var item in placements) { - if (item.RowSpan == 1 && item.Row >= fixedRowTracks.Count) + if (item.RowSpan == 1 && (item.Row >= rowTracks.Count + || rowTracks[item.Row].AcceptsIntrinsicContribution)) { rowHeights[item.Row] = Math.Max( rowHeights[item.Row], @@ -1305,6 +1309,13 @@ private static void ArrangeFixedPixelGrid( } var columnGap = Math.Max(0, style.ColumnGap.Resolve(content.Width) ?? 0); var rowGap = Math.Max(0, style.RowGap.Resolve(content.Height) ?? 0); + CssGridTrackList.DistributeRemainingSpace( + rowHeights, + rowTracks, + content.Height, + rowGap * Math.Max(0, rowHeights.Length - 1), + rowTemplateSupportsDistribution + && style.AlignContent == CssLayoutAlignContent.Stretch); var columnOffsets = new double[tracks.Count]; for (var index = 1; index < columnOffsets.Length; index++) { diff --git a/src/WebScene.Css/CssGridTrackList.cs b/src/WebScene.Css/CssGridTrackList.cs new file mode 100644 index 000000000..785e19393 --- /dev/null +++ b/src/WebScene.Css/CssGridTrackList.cs @@ -0,0 +1,198 @@ +using System.Globalization; + +namespace WebScene.Css; + +internal readonly record struct CssGridTrack( + double BaseSize, + bool AcceptsIntrinsicContribution, + bool MaximumIsAuto, + double Fraction = 0); + +internal static class CssGridTrackList +{ + public static bool TryParseRows( + string value, + double percentageBasis, + out CssGridTrack[] tracks) + { + var tokens = Tokenize(value); + tracks = new CssGridTrack[tokens.Count]; + if (tokens.Count == 0) return false; + for (var index = 0; index < tokens.Count; index++) + { + if (!TryParseTrack(tokens[index], percentageBasis, out tracks[index])) + { + tracks = []; + return false; + } + } + return true; + } + + public static void DistributeRemainingSpace( + double[] sizes, + IReadOnlyList tracks, + double availableHeight, + double totalGap, + bool stretches) + { + if (!stretches || !double.IsFinite(availableHeight)) return; + var remaining = availableHeight - totalGap - sizes.Sum(); + if (remaining <= 0) return; + var fractionalWeight = tracks.Sum(static track => track.Fraction); + if (fractionalWeight > 0) + { + for (var index = 0; index < Math.Min(sizes.Length, tracks.Count); index++) + { + if (tracks[index].Fraction > 0) + sizes[index] += remaining * tracks[index].Fraction / fractionalWeight; + } + return; + } + var stretchable = Enumerable.Range(0, sizes.Length) + .Where(index => index >= tracks.Count || tracks[index].MaximumIsAuto) + .ToArray(); + if (stretchable.Length == 0) return; + var share = remaining / stretchable.Length; + foreach (var index in stretchable) sizes[index] += share; + } + + private static bool TryParseTrack( + string token, + double percentageBasis, + out CssGridTrack track) + { + token = token.Trim(); + if (token.Equals("auto", StringComparison.OrdinalIgnoreCase)) + { + track = new(0, true, true); + return true; + } + if (token.Equals("min-content", StringComparison.OrdinalIgnoreCase) + || token.Equals("max-content", StringComparison.OrdinalIgnoreCase)) + { + track = new(0, true, false); + return true; + } + if (TryParseFraction(token, out var fraction)) + { + track = new(0, false, false, fraction); + return true; + } + if (token.StartsWith("minmax(", StringComparison.OrdinalIgnoreCase) + && token.EndsWith(')')) + { + var arguments = TokenizeArguments(token[7..^1]); + if (arguments is null + || !TryParseMinimum(arguments.Value.Minimum, percentageBasis, out var minimum)) + { + track = default; + return false; + } + var maximumIsAuto = arguments.Value.Maximum.Equals( + "auto", StringComparison.OrdinalIgnoreCase); + if (!maximumIsAuto + && !arguments.Value.Maximum.Equals("min-content", StringComparison.OrdinalIgnoreCase) + && !arguments.Value.Maximum.Equals("max-content", StringComparison.OrdinalIgnoreCase) + && !TryParseLength(arguments.Value.Maximum, percentageBasis, out _)) + { + track = default; + return false; + } + track = new(minimum, true, maximumIsAuto); + return true; + } + if (TryParseLength(token, percentageBasis, out var size)) + { + track = new(size, false, false); + return true; + } + track = default; + return false; + } + + private static bool TryParseMinimum(string value, double basis, out double minimum) + { + if (value.Equals("auto", StringComparison.OrdinalIgnoreCase) + || value.Equals("min-content", StringComparison.OrdinalIgnoreCase) + || value.Equals("max-content", StringComparison.OrdinalIgnoreCase)) + { + minimum = 0; + return true; + } + return TryParseLength(value, basis, out minimum); + } + + private static bool TryParseLength(string value, double basis, out double result) + { + value = value.Trim(); + if (value == "0") + { + result = 0; + return true; + } + var percentage = value.EndsWith('%'); + var pixels = value.EndsWith("px", StringComparison.OrdinalIgnoreCase); + var suffix = percentage ? 1 : pixels ? 2 : 0; + if (suffix == 0 + || !double.TryParse(value.AsSpan(0, value.Length - suffix), + NumberStyles.Float, CultureInfo.InvariantCulture, out var number) + || !double.IsFinite(number) || number < 0) + { + result = 0; + return false; + } + result = percentage ? basis * number / 100 : number; + return true; + } + + private static bool TryParseFraction(string value, out double result) + { + value = value.Trim(); + if (!value.EndsWith("fr", StringComparison.OrdinalIgnoreCase) + || !double.TryParse(value.AsSpan(0, value.Length - 2), + NumberStyles.Float, CultureInfo.InvariantCulture, out result) + || !double.IsFinite(result) || result <= 0) + { + result = 0; + return false; + } + return true; + } + + private static List Tokenize(string value) + { + var result = new List(); + var start = -1; + var depth = 0; + for (var index = 0; index <= value.Length; index++) + { + var character = index < value.Length ? value[index] : ' '; + if (character == '(') depth++; + else if (character == ')' && depth > 0) depth--; + var separator = depth == 0 && char.IsWhiteSpace(character); + if (!separator && start < 0) start = index; + if (separator && start >= 0) + { + result.Add(value[start..index]); + start = -1; + } + } + return result; + } + + private static (string Minimum, string Maximum)? TokenizeArguments(string value) + { + var depth = 0; + for (var index = 0; index < value.Length; index++) + { + if (value[index] == '(') depth++; + else if (value[index] == ')') depth--; + else if (value[index] == ',' && depth == 0) + { + return (value[..index].Trim(), value[(index + 1)..].Trim()); + } + } + return null; + } +} diff --git a/src/WebScene.Css/CssMeasurementEngine.cs b/src/WebScene.Css/CssMeasurementEngine.cs index bfb5feba2..740231306 100644 --- a/src/WebScene.Css/CssMeasurementEngine.cs +++ b/src/WebScene.Css/CssMeasurementEngine.cs @@ -655,8 +655,10 @@ private static WebSceneSize MeasureGrid( { if (TryParseFixedPixelTracks(root.Style.GridTemplateColumns, out var fixedTracks)) { - _ = TryParseFixedPixelTracks(root.Style.GridTemplateRows, out var fixedRowTracks); - return MeasureFixedPixelGrid(root, available, measurer, fixedTracks, fixedRowTracks); + var parsedRows = CssGridTrackList.TryParseRows( + root.Style.GridTemplateRows, FiniteOrZero(available.Height), out var rowTracks); + return MeasureFixedPixelGrid(root, available, measurer, fixedTracks, rowTracks, + parsedRows || string.IsNullOrWhiteSpace(root.Style.GridTemplateRows)); } if (UsesAutoFractionColumns(root.Style)) @@ -691,13 +693,14 @@ private static WebSceneSize MeasureFixedPixelGrid( WebSceneSize available, ICssIntrinsicMeasurer measurer, IReadOnlyList tracks, - IReadOnlyList fixedRowTracks) + IReadOnlyList rowTracks, + bool rowTemplateSupportsDistribution) { var availableWidth = FiniteOrInfinity(available.Width); var availableHeight = FiniteOrInfinity(available.Height); 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(); + var rows = rowTracks.Select(static track => track.BaseSize).ToList(); _ = CssGridTemplateAreas.TryParse(root.Style.GridTemplateAreas, out var namedAreas); var row = 0; var column = 0; @@ -726,8 +729,8 @@ private static WebSceneSize MeasureFixedPixelGrid( var trackWidth = tracks.Skip(itemColumn).Take(columnSpan).Sum() + columnGap * Math.Max(0, columnSpan - 1); var declaredWidth = ResolveForMeasure(child.Style.Width, trackWidth); - var rowTrackHeight = itemRow + rowSpan <= fixedRowTracks.Count - ? fixedRowTracks.Skip(itemRow).Take(rowSpan).Sum() + var rowTrackHeight = itemRow + rowSpan <= rowTracks.Count + ? rows.Skip(itemRow).Take(rowSpan).Sum() + rowGap * Math.Max(0, rowSpan - 1) : (double?)null; var declaredHeight = ResolveForMeasure(child.Style.Height, rowTrackHeight ?? availableHeight); @@ -747,7 +750,8 @@ private static WebSceneSize MeasureFixedPixelGrid( child.Style.BoxSizing) ?? measured.Height) + metrics.Margin.Vertical; while (rows.Count < itemRow + rowSpan) rows.Add(0); - if (rowSpan == 1 && itemRow >= fixedRowTracks.Count) + if (rowSpan == 1 && (itemRow >= rowTracks.Count + || rowTracks[itemRow].AcceptsIntrinsicContribution)) { rows[itemRow] = Math.Max(rows[itemRow], itemHeight); } @@ -768,7 +772,17 @@ private static WebSceneSize MeasureFixedPixelGrid( } var desiredWidth = tracks.Sum() + columnGap * Math.Max(0, tracks.Count - 1); - var desiredHeight = rows.Sum() + rowGap * Math.Max(0, rows.Count - 1); + var rowSizes = rows.ToArray(); + var totalRowGap = rowGap * Math.Max(0, rowSizes.Length - 1); + CssGridTrackList.DistributeRemainingSpace( + rowSizes, + rowTracks, + availableHeight, + totalRowGap, + rowTemplateSupportsDistribution + && root.Style.AlignContent == CssLayoutAlignContent.Stretch + && !root.Style.Height.IsAuto); + var desiredHeight = rowSizes.Sum() + totalRowGap; return new WebSceneSize( LimitToAvailable(desiredWidth, availableWidth), LimitToAvailable(desiredHeight, availableHeight)); diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index 99656fafb..beae20969 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -227,10 +227,10 @@ }, { "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", "rectangular named template areas", "sparse dot cells", "generated area line placement", "atomic area-template validation", "area-template mutation invalidation"], + "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", "auto-maximum row stretch in definite grids", "multi-row remaining-space distribution"], "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", "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"], + "coverage": ["css/css-grid/alignment/grid-gutters-001.html", "contracts/css-grid-placement-cssom.html", "contracts/css-grid-template-areas.html", "contracts/css-grid-auto-max-track-stretch.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_grid_auto_maximum_tracks_stretch_remaining_space", "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." }, { diff --git a/tests/WebPlatformSubset/contracts/css-grid-auto-max-track-stretch.html b/tests/WebPlatformSubset/contracts/css-grid-auto-max-track-stretch.html new file mode 100644 index 000000000..aed6adaeb --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-grid-auto-max-track-stretch.html @@ -0,0 +1,22 @@ + +CSS Grid auto maximum tracks stretch remaining block space + + +
+
+
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index 4b76efa74..592a644e2 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -526,6 +526,13 @@ "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": "contracts/css-grid-auto-max-track-stretch.html", + "type": "contract", + "capabilities": ["grid-auto-maximum-track-stretch", "multi-row-align-content-stretch", "grid-track-height-mutation-publication"], + "evidence": ["code-oss-welcome-auto-middle-row", "webscene-issue-106"], + "reason": "Product-neutral reduction of the auto-maximum track sizing used by Code OSS Welcome. It compares exact Chromium/native geometry for minmax(min-content, auto), multiple stretchable rows with gaps, max-content exclusion, non-stretch align-content, synchronous height mutation, and a bounded 500-mutation publication 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 6be3d0fae..7998b9fb0 100644 --- a/tests/WebScene.Css.Tests/CssArrangementEngineTests.cs +++ b/tests/WebScene.Css.Tests/CssArrangementEngineTests.cs @@ -404,6 +404,114 @@ public void FixedPixelGridPlacesNamedAreasAcrossSparseTracks() Assert.Equal(new WebSceneRect(0, 34, 94, 15), snapshot[5].BorderBox); } + [Fact] + public void GridStretchesMinmaxAutoMaximumAcrossRemainingBlockSpace() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + GridTemplateColumns = "700px", + GridTemplateRows = "25% minmax(min-content, auto) min-content", + GridTemplateAreas = "\"header\" \"middle\" \"footer\"" + }); + root.Add(new CssLayoutNode(2, new CssLayoutStyle + { GridArea = "header", Height = CssLayoutLength.Pixels(40) })); + root.Add(new CssLayoutNode(3, new CssLayoutStyle + { GridArea = "middle", Height = CssLayoutLength.Pixels(200) })); + root.Add(new CssLayoutNode(4, new CssLayoutStyle + { GridArea = "footer", Height = CssLayoutLength.Pixels(18) })); + + var snapshot = new CssArrangementEngine().Arrange(root, new WebSceneSize(700, 400)); + + Assert.Equal(new WebSceneRect(0, 0, 700, 40), snapshot[2].BorderBox); + Assert.Equal(new WebSceneRect(0, 100, 700, 200), snapshot[3].BorderBox); + Assert.Equal(new WebSceneRect(0, 382, 700, 18), snapshot[4].BorderBox); + } + + [Fact] + public void GridDoesNotStretchAutoMaximumForFlexStartAlignContent() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + AlignContent = CssLayoutAlignContent.FlexStart, + GridTemplateColumns = "700px", + GridTemplateRows = "25% minmax(min-content, auto) min-content", + GridTemplateAreas = "\"header\" \"middle\" \"footer\"" + }); + root.Add(new CssLayoutNode(2, new CssLayoutStyle + { GridArea = "header", Height = CssLayoutLength.Pixels(40) })); + root.Add(new CssLayoutNode(3, new CssLayoutStyle + { GridArea = "middle", Height = CssLayoutLength.Pixels(200) })); + root.Add(new CssLayoutNode(4, new CssLayoutStyle + { GridArea = "footer", Height = CssLayoutLength.Pixels(18) })); + + var snapshot = new CssArrangementEngine().Arrange(root, new WebSceneSize(700, 400)); + + Assert.Equal(300, snapshot[4].BorderBox.Y); + } + + [Fact] + public void GridSharesRemainingSpaceAcrossAutoMaximumRowsWithGaps() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + GridTemplateColumns = "100px", + GridTemplateRows = "minmax(0, auto) auto 20px", + RowGap = CssLayoutLength.Pixels(5) + }); + root.Add(new CssLayoutNode(2) { IntrinsicSize = new WebSceneSize(10, 10) }); + root.Add(new CssLayoutNode(3) { IntrinsicSize = new WebSceneSize(10, 10) }); + root.Add(new CssLayoutNode(4) { IntrinsicSize = new WebSceneSize(10, 10) }); + + var snapshot = new CssArrangementEngine().Arrange(root, new WebSceneSize(100, 100)); + + Assert.Equal(new WebSceneRect(0, 0, 100, 35), snapshot[2].BorderBox); + Assert.Equal(new WebSceneRect(0, 40, 100, 35), snapshot[3].BorderBox); + Assert.Equal(new WebSceneRect(0, 80, 100, 20), snapshot[4].BorderBox); + } + + [Fact] + public void GridAllocatesRemainingSpaceToFractionalRowsBeforeAutoMaximumStretch() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + GridTemplateColumns = "100px", + GridTemplateRows = "100px 1fr auto" + }); + root.Add(new CssLayoutNode(2) { IntrinsicSize = new WebSceneSize(10, 40) }); + root.Add(new CssLayoutNode(3) { IntrinsicSize = new WebSceneSize(10, 200) }); + root.Add(new CssLayoutNode(4) { IntrinsicSize = new WebSceneSize(10, 18) }); + + var snapshot = new CssArrangementEngine().Arrange(root, new WebSceneSize(100, 400)); + + Assert.Equal(new WebSceneRect(0, 0, 100, 100), snapshot[2].BorderBox); + Assert.Equal(new WebSceneRect(0, 100, 100, 282), snapshot[3].BorderBox); + Assert.Equal(new WebSceneRect(0, 382, 100, 18), snapshot[4].BorderBox); + } + + [Fact] + public void UnsupportedRowTemplateIsNotReclassifiedAsImplicitAutoRows() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + GridTemplateColumns = "100px", + GridTemplateRows = "repeat(3, 1fr)" + }); + root.Add(new CssLayoutNode(2) { IntrinsicSize = new WebSceneSize(10, 10) }); + root.Add(new CssLayoutNode(3) { IntrinsicSize = new WebSceneSize(10, 10) }); + root.Add(new CssLayoutNode(4) { IntrinsicSize = new WebSceneSize(10, 10) }); + + var snapshot = new CssArrangementEngine().Arrange(root, new WebSceneSize(100, 300)); + + Assert.Equal(0, snapshot[2].BorderBox.Y); + Assert.Equal(10, snapshot[3].BorderBox.Y); + Assert.Equal(20, snapshot[4].BorderBox.Y); + } + [Theory] [InlineData(CssLayoutJustifyContent.FlexEnd, 60)] [InlineData(CssLayoutJustifyContent.Center, 30)] diff --git a/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs b/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs index 6773179ab..50199f8c4 100644 --- a/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs +++ b/tests/WebScene.Css.Tests/CssMeasurementEngineTests.cs @@ -284,6 +284,34 @@ public void FixedPixelGridMeasuresNamedAreaSpans() Assert.Equal(94, measurer.Constraints[5].Width); } + [Fact] + public void GridMeasurementPublishesDefiniteHeightAfterAutoMaximumStretch() + { + var root = new CssLayoutNode(1, new CssLayoutStyle + { + Display = CssLayoutDisplay.Grid, + Height = CssLayoutLength.Pixels(400), + GridTemplateColumns = "700px", + GridTemplateRows = "25% minmax(min-content, auto) min-content", + GridTemplateAreas = "\"header\" \"middle\" \"footer\"" + }); + root.Add(new CssLayoutNode(2, new CssLayoutStyle + { GridArea = "header", Height = CssLayoutLength.Pixels(40) })); + root.Add(new CssLayoutNode(3, new CssLayoutStyle + { GridArea = "middle", Height = CssLayoutLength.Pixels(200) })); + root.Add(new CssLayoutNode(4, new CssLayoutStyle + { GridArea = "footer", Height = CssLayoutLength.Pixels(18) })); + var measurer = new RecordingMeasurer( + (2, new WebSceneSize(700, 40)), + (3, new WebSceneSize(700, 200)), + (4, new WebSceneSize(700, 18))); + + var desired = new CssMeasurementEngine().Measure( + root, new WebSceneSize(700, 400), measurer); + + Assert.Equal(new WebSceneSize(700, 400), desired); + } + [Fact] public void TableMeasurementSharesIntrinsicColumnWidthsAcrossRows() {