From 53886d75904aa1b9210dbf98f961873cc88c4c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Mon, 7 Sep 2026 06:50:54 +0700 Subject: [PATCH 1/4] fix(flex): stop emitting Expanded/Flexible under Wrap for flex-wrap:wrap (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `display:flex; flex-wrap:wrap` container was mapped to Flutter's `Wrap`, but its children were still wrapped in `FlexItemWidget`, which builds `Expanded` (flex-grow > 0) or `Flexible` (shrinkable). `Wrap` provides `WrapParentData`, so Flutter asserted Incorrect use of ParentDataWidget … wants to apply ParentData of type FlexParentData to a RenderObject … set up to accept WrapParentData and the broken parent data cascaded into `RenderFlex children have non-zero flex but incoming width constraints are unbounded`, `RenderBox was not laid out` and `child.hasSize is not true` for the rest of the document. The trigger is the ordinary responsive-card pattern `flex: 1 1 220px` + `min-width: 220px`. Wrapping flex is now laid out arithmetically in `FlexContainerWidget`: items are packed into lines by their base size (`flex-basis` → `width` → `min-width`, 0 for a growable item), each line's free space is distributed in proportion to `flex-grow`, widths are clamped to `min-width`/`max-width`, and the result is emitted as a `Column` of `Row`s. Because widths are resolved numerically, no `Expanded`/`Flexible` is needed at all — `Expanded` would also have been wrong, since CSS distributes *free* space on top of each item's basis rather than dividing the whole line. Shapes that cannot be sized at build time keep the `Wrap` path: `flex-direction: column*` (unbounded cross axis), `row-reverse` / `wrap-reverse`, an unbounded main axis, and item sets containing a non-flex child or one with no knowable base. That path is now safe too — `_buildStrippedWrapChildren` replaces every `FlexItemWidget` with its unflexed child plus explicit `flex-basis`/`min-width`/`max-width` sizing, so flex parent data can no longer reach a `Wrap` by any route. Also fixes a pre-existing crash the new path would otherwise have inherited: CSS `align-items: baseline` produced `CrossAxisAlignment.baseline` with no `textBaseline`, which asserts in both `Row` and `Column`. Both the nowrap path and the new wrapping path now pass `TextBaseline.alphabetic`. The nowrap sizing behaviour is otherwise untouched. Side effect: `flex-basis`, `min-width` and `max-width` were parsed into `ComputedStyle` but read by nothing on the flex path; they now participate in wrapping-flex sizing. `CSS_PROPERTIES_MATRIX.md` downgrades `flex-basis` to ⚠️ accordingly — it executes only for wrapping containers, and `%`/`auto` bases are still unparsed. test/style/flex_wrap_test.dart adds 15 regression tests asserting geometry, not just the absence of an exception: card widths and row membership at 500px and 800px, `min-width` winning over a smaller basis, bare `flex: 1` splitting evenly, plus explicit `findsNothing` checks for `Expanded`/`Flexible` under `Wrap`, the `align-items`/`align-self` shapes under an unbounded height, and fallback coverage for the column/wrap-reverse/mixed-children cases. The original 11 all fail without this change. Suite: 2355 root+core, 28 golden. Closes #15 --- CHANGELOG.md | 8 + doc/CSS_PROPERTIES_MATRIX.md | 4 +- .../src/widgets/flex_container_widget.dart | 347 +++++++++++++++++- test/style/flex_wrap_test.dart | 269 ++++++++++++++ 4 files changed, 614 insertions(+), 14 deletions(-) create mode 100644 test/style/flex_wrap_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcda89..688c0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### 🐛 Fixes + +- **`display:flex; flex-wrap:wrap` with flex children crashed the frame** ([#15](https://github.com/brewkits/hyper_render/issues/15)): a wrapping flex container was mapped to Flutter's `Wrap`, but its items were still wrapped in `FlexItemWidget`, which emits `Expanded`/`Flexible`. `Wrap` provides `WrapParentData`, so Flutter threw *"Incorrect use of ParentDataWidget … wants to apply ParentData of type FlexParentData"* and cascaded into `RenderBox was not laid out` / `child.hasSize is not true` for the rest of the document. Wrapping flex is now laid out arithmetically — items are packed into lines by their base size and each line's free space is distributed in proportion to `flex-grow` — and emitted as a `Column` of `Row`s, so no flex parent data ever reaches a `Wrap`. Shapes that cannot be sized at build time (`flex-direction: column`, `row-reverse`/`wrap-reverse`, unbounded width, items with no knowable base) still fall back to `Wrap`, but with every `Expanded`/`Flexible` stripped and replaced by `flex-basis`/`min-width`/`max-width` sizing. +- **CSS `align-items: baseline` asserted on every flex container**: `CrossAxisAlignment.baseline` was handed to `Row`/`Column` without a `textBaseline`, tripping *"textBaseline is required if you specify the crossAxisAlignment with CrossAxisAlignment.baseline"*. Both paths now pass `TextBaseline.alphabetic`. +- **`flex-basis`, `min-width` and `max-width` were parsed but never applied to flex items**: `flex: 1 1 220px; min-width: 220px` sized from content instead of the declared basis. All three now participate in wrapping-flex sizing. + ## 1.8.0 - **AI & LLM Real-Time Token Streaming Engine**: diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md index 0951956..e349a17 100644 --- a/doc/CSS_PROPERTIES_MATRIX.md +++ b/doc/CSS_PROPERTIES_MATRIX.md @@ -72,11 +72,11 @@ This document lists CSS property support in HyperRender. | Property | Status | Supported Values | Notes | |----------|--------|------------------|-------| | `flex-direction` | ✅ | row, column, row-reverse, column-reverse | | -| `flex-wrap` | ✅ | nowrap, wrap, wrap-reverse | | +| `flex-wrap` | ✅ | nowrap, wrap, wrap-reverse | `wrap` on a `row` container packs items into lines and distributes free space by `flex-grow`; `wrap-reverse` and `flex-direction: column` fall back to Flutter `Wrap` (items keep their base size, no growth) | | `flex` | ✅ | \ \ \ | Shorthand | | `flex-grow` | ✅ | number | | | `flex-shrink` | ✅ | number | | -| `flex-basis` | ✅ | px, %, auto | | +| `flex-basis` | ⚠️ | px | Applied on `flex-wrap: wrap` containers only — the `nowrap` Row/Column path sizes from content. `%` and `auto` are not parsed and resolve to 0 (a growable item then splits the line evenly, matching a browser's `flex: 1`) | | `justify-content` | ✅ | flex-start, center, flex-end, space-between, space-around | | | `align-items` | ✅ | flex-start, center, flex-end, stretch, baseline | | | `align-content` | ✅ | flex-start, center, flex-end, space-between, space-around | | diff --git a/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart b/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart index b511752..a78971e 100644 --- a/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart +++ b/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../model/computed_style.dart'; @@ -95,6 +97,11 @@ class FlexContainerWidget extends StatelessWidget { mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: effectiveCrossAxis, mainAxisSize: MainAxisSize.max, + // Row asserts when crossAxisAlignment is baseline and textBaseline is + // null. CSS `align-items: baseline` maps to the alphabetic baseline. + textBaseline: effectiveCrossAxis == CrossAxisAlignment.baseline + ? TextBaseline.alphabetic + : null, textDirection: isReverse ? TextDirection.rtl : TextDirection.ltr, children: processedChildren, ); @@ -117,6 +124,9 @@ class FlexContainerWidget extends StatelessWidget { mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: crossAxisAlignment, mainAxisSize: hasExplicitHeight ? MainAxisSize.max : MainAxisSize.min, + textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline + ? TextBaseline.alphabetic + : null, verticalDirection: isReverse ? VerticalDirection.up : VerticalDirection.down, children: axisAwareChildren.map((child) { @@ -138,17 +148,48 @@ class FlexContainerWidget extends StatelessWidget { ); } } else { - // Use Wrap for wrapping flex - final bool reverseWrap = style.flexWrap == FlexWrap.wrapReverse; - flexWidget = Wrap( - direction: axis, - alignment: wrapAlignment, - crossAxisAlignment: wrapCrossAlignment, - spacing: mainAxisSpacing, - runSpacing: crossAxisSpacing, - verticalDirection: - reverseWrap ? VerticalDirection.up : VerticalDirection.down, - children: children, + // Wrapping flex (`flex-wrap: wrap` / `wrap-reverse`). + // + // Flutter's `Wrap` provides `WrapParentData`, so an `Expanded`/`Flexible` + // emitted by [FlexItemWidget] underneath it trips + // "Incorrect use of ParentDataWidget" and cascades into a broken frame + // (issue #15). Two strategies, both of which guarantee that no flex + // parent data is ever attached to a `Wrap` child: + // + // 1. `_buildFlexLines` — a real CSS wrapping-flex layout (line packing + + // free-space distribution) emitted as a Column of Rows. Rows are + // `Flex`es, and widths are resolved arithmetically, so no + // `Expanded`/`Flexible` is needed at all. + // 2. `_buildStrippedWrap` — fallback for shapes strategy 1 cannot size at + // build time (column wrap, unknown-size items, unbounded width): a + // plain `Wrap` whose `FlexItemWidget` children are replaced by + // `flex-basis`/`min-width`/`max-width` sizing widgets. + flexWidget = LayoutBuilder( + builder: (context, constraints) { + final lines = _buildFlexLines( + constraints: constraints, + axis: axis, + isReverse: isReverse, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + containerStyle: style, + ); + if (lines != null) return lines; + + final bool reverseWrap = style.flexWrap == FlexWrap.wrapReverse; + return Wrap( + direction: axis, + alignment: wrapAlignment, + crossAxisAlignment: wrapCrossAlignment, + spacing: mainAxisSpacing, + runSpacing: crossAxisSpacing, + verticalDirection: + reverseWrap ? VerticalDirection.up : VerticalDirection.down, + children: _buildStrippedWrapChildren(axis, constraints), + ); + }, ); } @@ -251,6 +292,239 @@ class FlexContainerWidget extends StatelessWidget { } } + /// Builds a wrapping flex container as a `Column` of `Row`s, resolving CSS + /// `flex-basis` / `flex-grow` / `flex-shrink` / `min-width` / `max-width` + /// arithmetically. + /// + /// Returns `null` when the container's shape cannot be resolved at build + /// time, in which case the caller falls back to a plain (flex-parent-data + /// free) `Wrap`. Bailing out covers: + /// * `flex-direction: column*` — the cross axis is height, which is + /// unbounded here, so lines cannot be packed; + /// * `row-reverse` / `wrap-reverse` — ordering is left to `Wrap`; + /// * an unbounded/degenerate main-axis extent; + /// * a child that is not a [FlexItemWidget], or one whose base size is not + /// knowable at build time (no `flex-basis`/`width`/`min-width` and no + /// `flex-grow` to size it from free space). + Widget? _buildFlexLines({ + required BoxConstraints constraints, + required Axis axis, + required bool isReverse, + required double mainAxisSpacing, + required double crossAxisSpacing, + required MainAxisAlignment mainAxisAlignment, + required CrossAxisAlignment crossAxisAlignment, + required ComputedStyle containerStyle, + }) { + if (axis != Axis.horizontal || isReverse) return null; + if (containerStyle.flexWrap == FlexWrap.wrapReverse) return null; + if (children.isEmpty) return null; + + final double available = constraints.maxWidth; + if (!available.isFinite || available <= 0) return null; + + final items = <_ResolvedFlexItem>[]; + for (final child in children) { + if (child is! FlexItemWidget) return null; + final ComputedStyle s = child.style; + final double grow = s.flexGrow ?? 0; + final double shrink = s.flexShrink ?? 1; + // CSS `flex-basis: auto` falls back to `width`; an unparsed basis + // (`0%`, `auto`) resolves to null and is treated as 0 for growable items. + final double? explicitBase = s.flexBasis ?? s.width ?? s.minWidth; + if (explicitBase == null && grow <= 0) return null; + + final double minWidth = s.minWidth ?? 0; + final double maxWidth = s.maxWidth ?? double.infinity; + double base = explicitBase ?? 0; + base = base.clamp(minWidth, math.max(minWidth, maxWidth)); + base = base.clamp(0.0, available); + + items.add(_ResolvedFlexItem( + item: child, + base: base, + grow: grow, + shrink: shrink, + minWidth: math.min(minWidth, available), + maxWidth: maxWidth, + )); + } + + // Pack items into lines: an item starts a new line when it no longer fits + // in the remaining main-axis extent (gaps included). + final lines = >[]; + var current = <_ResolvedFlexItem>[]; + double currentExtent = 0; + for (final item in items) { + final double candidate = current.isEmpty + ? item.base + : currentExtent + mainAxisSpacing + item.base; + if (current.isNotEmpty && candidate > available + _epsilon) { + lines.add(current); + current = <_ResolvedFlexItem>[]; + currentExtent = 0; + } + currentExtent = current.isEmpty + ? item.base + : currentExtent + mainAxisSpacing + item.base; + current.add(item); + } + if (current.isNotEmpty) lines.add(current); + + final hasStretch = crossAxisAlignment == CrossAxisAlignment.stretch || + items.any((i) => i.item.style.alignSelf == AlignItems.stretch); + + final rows = []; + for (var l = 0; l < lines.length; l++) { + if (l > 0 && crossAxisSpacing > 0) { + rows.add(SizedBox(height: crossAxisSpacing)); + } + rows.add(_buildFlexLine( + line: lines[l], + available: available, + mainAxisSpacing: mainAxisSpacing, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + useIntrinsicHeight: hasStretch, + )); + } + + if (rows.length == 1) return rows.first; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: rows, + ); + } + + /// Resolves one flex line's item widths and emits it as a [Row]. + /// + /// Widths are computed here rather than delegated to `Expanded`/`Flexible`, + /// because CSS distributes *free space* in proportion to `flex-grow` on top + /// of each item's base size, whereas `Expanded` divides the whole line. + Widget _buildFlexLine({ + required List<_ResolvedFlexItem> line, + required double available, + required double mainAxisSpacing, + required MainAxisAlignment mainAxisAlignment, + required CrossAxisAlignment crossAxisAlignment, + required bool useIntrinsicHeight, + }) { + final double gaps = mainAxisSpacing * (line.length - 1); + double totalBase = 0; + for (final i in line) { + totalBase += i.base; + } + final double free = available - gaps - totalBase; + + final widths = []; + if (free > _epsilon) { + double totalGrow = 0; + for (final i in line) { + totalGrow += i.grow; + } + for (final i in line) { + widths + .add(totalGrow > 0 ? i.base + free * (i.grow / totalGrow) : i.base); + } + } else if (free < -_epsilon) { + // Defensive only: line packing never emits a line wider than `available` + // (each base is clamped to it, and an item that would overflow starts a + // new line), so this branch is currently unreachable. It is kept so a + // future packing change degrades into CSS shrink rather than overflow. + double totalScaled = 0; + for (final i in line) { + totalScaled += i.shrink * i.base; + } + for (final i in line) { + widths.add(totalScaled > 0 + ? i.base + free * ((i.shrink * i.base) / totalScaled) + : i.base); + } + } else { + for (final i in line) { + widths.add(i.base); + } + } + + final rowChildren = []; + for (var i = 0; i < line.length; i++) { + if (i > 0 && mainAxisSpacing > 0) { + rowChildren.add(SizedBox(width: mainAxisSpacing)); + } + final resolved = line[i]; + final double width = widths[i] + .clamp( + resolved.minWidth, math.max(resolved.minWidth, resolved.maxWidth)) + .clamp(0.0, available) + .toDouble(); + rowChildren.add(SizedBox( + width: width, + child: resolved.item.buildUnflexed(parentAxis: Axis.horizontal), + )); + } + + Widget row = Row( + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + mainAxisSize: MainAxisSize.max, + textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline + ? TextBaseline.alphabetic + : null, + children: rowChildren, + ); + // No Expanded/Flexible is emitted above, so IntrinsicHeight is safe and is + // what bounds `align-self: stretch`'s SizedBox(height: infinity). + if (useIntrinsicHeight) row = IntrinsicHeight(child: row); + return row; + } + + /// Fallback path: the children a plain `Wrap` may legally receive. + /// + /// Every [FlexItemWidget] is replaced by its unflexed child plus explicit + /// sizing from `flex-basis` / `min-width` / `max-width`, so no `Expanded` or + /// `Flexible` is ever attached to `WrapParentData` (issue #15). + List _buildStrippedWrapChildren( + Axis axis, BoxConstraints constraints) { + return children.map((child) { + if (child is! FlexItemWidget) return child; + final ComputedStyle s = child.style; + // `align-self: stretch` builds SizedBox(height: infinity), which needs a + // bounded cross axis; a Wrap row does not provide one, so drop it here. + final Widget inner = child.buildUnflexed( + parentAxis: axis, + allowStretch: axis != Axis.horizontal, + ); + + if (axis != Axis.horizontal) { + final basis = s.flexBasis; + return basis != null ? SizedBox(height: basis, child: inner) : inner; + } + + final double bound = constraints.maxWidth; + final double limit = + bound.isFinite && bound > 0 ? bound : double.infinity; + final double minWidth = math.min(s.minWidth ?? 0, limit); + final double maxWidth = + math.max(minWidth, math.min(s.maxWidth ?? double.infinity, limit)); + + final basis = s.flexBasis; + if (basis != null) { + return SizedBox( + width: basis.clamp(minWidth, maxWidth), + child: inner, + ); + } + if (minWidth > 0 || maxWidth.isFinite) { + return ConstrainedBox( + constraints: BoxConstraints(minWidth: minWidth, maxWidth: maxWidth), + child: inner, + ); + } + return inner; + }).toList(); + } + List _buildChildrenWithGap( List children, double gap, Axis axis) { if (gap <= 0 || children.isEmpty) return children; @@ -314,7 +588,30 @@ class FlexItemWidget extends StatelessWidget { ); } - Widget _wrapWithAlignSelf(Widget child, AlignItems? alignSelf) { + /// Builds this item's child with `align-self` applied but **without** any + /// `Expanded`/`Flexible` wrapper. + /// + /// Used by containers that cannot accept flex parent data — notably `Wrap`, + /// which provides `WrapParentData` and asserts when handed `FlexParentData` + /// (issue #15) — and by the arithmetic wrapping-flex layout, which sizes + /// items itself. + /// + /// Set [allowStretch] to false when the cross axis is unbounded, so that + /// `align-self: stretch` does not emit an infinite-height box. + Widget buildUnflexed({ + required Axis parentAxis, + bool allowStretch = true, + }) { + var effective = style.alignSelf; + if (!allowStretch && effective == AlignItems.stretch) effective = null; + return _alignSelf(child, effective, parentAxis); + } + + Widget _wrapWithAlignSelf(Widget child, AlignItems? alignSelf) => + _alignSelf(child, alignSelf, parentAxis); + + static Widget _alignSelf( + Widget child, AlignItems? alignSelf, Axis parentAxis) { // align-self overrides the container's align-items for a specific item. // CrossAxisAlignment is per-container in Flutter, so we use Align/SizedBox // per-child as an approximation. @@ -367,3 +664,29 @@ class FlexItemWidget extends StatelessWidget { } } } + +/// Tolerance for main-axis extent comparisons, so that a line whose items sum +/// to exactly the available width does not wrap because of float error. +const double _epsilon = 0.01; + +/// One flex item with its CSS sizing inputs resolved to pixels. +class _ResolvedFlexItem { + final FlexItemWidget item; + + /// Base (pre-growth) main-axis size: `flex-basis`, else `width`, else + /// `min-width`, else 0 for growable items. + final double base; + final double grow; + final double shrink; + final double minWidth; + final double maxWidth; + + const _ResolvedFlexItem({ + required this.item, + required this.base, + required this.grow, + required this.shrink, + required this.minWidth, + required this.maxWidth, + }); +} diff --git a/test/style/flex_wrap_test.dart b/test/style/flex_wrap_test.dart new file mode 100644 index 0000000..732f0a2 --- /dev/null +++ b/test/style/flex_wrap_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +/// Regression tests for issue #15 — `display:flex; flex-wrap:wrap` with +/// children carrying a CSS `flex` shorthand used to emit +/// `Wrap → Expanded/Flexible`, which trips Flutter's +/// "Incorrect use of ParentDataWidget" assertion (`FlexParentData` applied to a +/// `RenderObject` set up for `WrapParentData`) and cascades into a broken +/// frame. +/// +/// The tests assert geometry, not merely the absence of an exception: a +/// "doesn't throw" test passes happily while the cards are sized wrongly. +void main() { + Future pumpHtml( + WidgetTester tester, + String html, { + required double width, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: width, child: HyperViewer(html: html)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// Same, but with an unbounded height (the normal HyperViewer setting: a + /// scroll view). Any `SizedBox(height: infinity)` from `align-self: stretch` + /// must still be bounded by the layout, or `IntrinsicHeight` blows up. + Future pumpScrolling( + WidgetTester tester, + String html, { + required double width, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SizedBox(width: width, child: HyperViewer(html: html)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// Width of the box that directly sizes the card containing [text]. + double cardWidth(WidgetTester tester, String text) { + final box = find + .ancestor(of: find.text(text), matching: find.byType(SizedBox)) + .first; + return tester.getSize(box).width; + } + + double cardTop(WidgetTester tester, String text) => + tester.getTopLeft(find.text(text)).dy; + + const cards = ''' +
+
Card 1
+
Card 2
+
Card 3
+
'''; + + group('issue #15 — flex-wrap:wrap with flex children', () { + testWidgets('renders without a ParentDataWidget assertion', (tester) async { + await pumpHtml(tester, cards, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('never attaches Expanded/Flexible beneath a Wrap', + (tester) async { + await pumpHtml(tester, cards, width: 500); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Expanded)), + findsNothing, + ); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Flexible)), + findsNothing, + ); + }); + + testWidgets('wraps to a second row when space is insufficient', + (tester) async { + // 500px container, 220px basis, 14px gap → 220+14+220 = 454 fits two + // cards; the third moves to a new row. + await pumpHtml(tester, cards, width: 500); + expect(tester.takeException(), isNull); + + expect(cardTop(tester, 'Card 1'), cardTop(tester, 'Card 2')); + expect(cardTop(tester, 'Card 3'), greaterThan(cardTop(tester, 'Card 1'))); + }); + + testWidgets('flex-grow distributes the free space on each row', + (tester) async { + await pumpHtml(tester, cards, width: 500); + expect(tester.takeException(), isNull); + + // Row 1: (500 - 14) / 2 = 243 each. Row 2: card 3 grows to the full 500. + expect(cardWidth(tester, 'Card 1'), closeTo(243, 0.5)); + expect(cardWidth(tester, 'Card 2'), closeTo(243, 0.5)); + expect(cardWidth(tester, 'Card 3'), closeTo(500, 0.5)); + }); + + testWidgets('all three fit on one row when the container is wide', + (tester) async { + // 220*3 + 14*2 = 688 ≤ 800 → single row, each grows to (800-28)/3 = 257.33 + await pumpHtml(tester, cards, width: 800); + expect(tester.takeException(), isNull); + + expect(cardTop(tester, 'Card 1'), cardTop(tester, 'Card 2')); + expect(cardTop(tester, 'Card 2'), cardTop(tester, 'Card 3')); + expect(cardWidth(tester, 'Card 1'), closeTo(772 / 3, 0.5)); + }); + + testWidgets('min-width is respected when it exceeds the flex basis', + (tester) async { + const html = ''' +
+
Wide
+
Narrow
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'Wide'), closeTo(180, 0.5)); + expect(cardWidth(tester, 'Narrow'), closeTo(50, 0.5)); + }); + + testWidgets('bare `flex: 1` children share the row equally', + (tester) async { + // `flex: 1` is `1 1 0%` — basis 0, so all items stay on one line and + // split the container evenly. + const html = ''' +
+
A
+
B
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect(cardTop(tester, 'A'), cardTop(tester, 'B')); + expect(cardWidth(tester, 'A'), closeTo(200, 0.5)); + expect(cardWidth(tester, 'B'), closeTo(200, 0.5)); + }); + + testWidgets('mixed flex and non-flex children fall back to Wrap safely', + (tester) async { + const html = ''' +
+
Flexy
+
Plain
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Expanded)), + findsNothing, + ); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Flexible)), + findsNothing, + ); + }); + + testWidgets('flex-direction:column + wrap falls back without asserting', + (tester) async { + const html = ''' +
+
Top
+
Bottom
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Expanded)), + findsNothing, + ); + }); + + testWidgets('wrap-reverse falls back to Wrap without asserting', + (tester) async { + const html = ''' +
+
One
+
Two
+
Three
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Expanded)), + findsNothing, + ); + expect( + find.descendant(of: find.byType(Wrap), matching: find.byType(Flexible)), + findsNothing, + ); + }); + + testWidgets('align-self:stretch stays bounded under an unbounded height', + (tester) async { + // The wrapping-flex rows are wrapped in IntrinsicHeight so that + // `align-self: stretch`'s SizedBox(height: infinity) has a finite bound + // even when the viewer sits in a scroll view. + const html = ''' +
+
Tall
+
Short
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'Tall'), closeTo(243, 0.5)); + }); + + testWidgets('align-items:stretch does not assert', (tester) async { + const html = ''' +
+
A
+
B
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('align-items:baseline supplies a textBaseline to the Row', + (tester) async { + // CrossAxisAlignment.baseline without a textBaseline trips + // "textBaseline is required if you specify the crossAxisAlignment with + // CrossAxisAlignment.baseline". + const html = ''' +
+
A
+
B
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('align-items:baseline does not assert on the nowrap path', + (tester) async { + const html = ''' +
+
A
+
B
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('many wrapping cards render without cascading errors', + (tester) async { + final buf = + StringBuffer('
'); + for (var i = 1; i <= 12; i++) { + buf.write('
Card $i
'); + } + buf.write('
'); + await pumpHtml(tester, buf.toString(), width: 700); + expect(tester.takeException(), isNull); + expect(find.text('Card 12'), findsOneWidget); + }); + }); +} From 1ed8088cb9e2fc9ffc52062797c7d85a43af72c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Mon, 7 Sep 2026 11:52:22 +0700 Subject: [PATCH 2/4] fix(flex): replace the wrapping-flex LayoutBuilder with a real render object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the issue #15 fix in the parent commit, which closed the reported crash but opened a second one in a neighbouring shape. The parent commit laid wrapping flex out as a LayoutBuilder-driven Column of Rows. LayoutBuilder cannot answer intrinsic or dry-layout queries, and CSS's `align-items` default is `stretch` (computed_style.dart) — so every wrapping container got an IntrinsicHeight, and any wrapping flex nested inside any flex container queried intrinsics across a LayoutBuilder and died with LayoutBuilder does not support returning intrinsic dimensions plus ~34 cascading `RenderBox was not laid out` errors. Those shapes were already broken before (1-3 ParentDataWidget errors), so this was not a regression from working — but it substituted a failure mode with a much larger blast radius, and nested card grids are a common shape. Horizontal wrapping flex is now `RenderFlexWrap`, a MultiChildRenderObject that resolves CSS sizing itself: it packs items into lines by base size, distributes each line's free space in proportion to `flex-grow`, clamps to `min-width`/`max-width`, and implements computeMinIntrinsicWidth / computeMaxIntrinsicWidth / computeMin|MaxIntrinsicHeight / computeDryLayout / paint / hitTestChildren. It emits no IntrinsicHeight at all (cross-axis stretch is a second layout pass on only the children that stretch), and no LayoutBuilder, so it composes with any ancestor. `flex-direction: column` + wrap keeps Flutter's `Wrap` — its main axis is height, which is unbounded here — with all flex parent data stripped. Sizing properties that were parsed but read by nothing now work: - `flex-basis` including `%` and `auto`; the `flex: 1` / `flex: 1 1` shorthands now imply CSS's `0%` basis instead of leaving it unset - `min-width`, including `%` for the first time (new `minWidthPercent`, following the existing `maxWidthPercent` pattern) - `max-width` as a cap on a grown item Measured before/after in a 500px container: `flex: 0 0 50%` gave 32.5px, now 250px; a bare `min-width: 300px` gave 32.5px, now 300px. Also fixed, both found while auditing the above: - An ` `-only flex item vanished. `_buildFlexChild` used `String.trim().isEmpty`, but Dart follows Unicode (U+00A0 is whitespace) while CSS Text Level 3 excludes it. Now uses `isCssWhitespaceOnly`, and trims only CSS whitespace so an edge ` ` survives. - `align-content` was marked supported for both flex and grid but is read by nothing in the render path; corrected to ❌ in the CSS matrix and added to docs_matrix_sync_test's must-not-be-full list so it cannot regress. Flex children now always carry their ComputedStyle via FlexItemWidget so the renderer can see `min-width` on items that declare no `flex-*`. For the nowrap Row/Column path this is behaviour-preserving, and the full suite confirms it: no nowrap test moved. test/style/flex_wrap_test.dart is rewritten, 15 tests -> 33. Its width helper previously matched `SizedBox`, which silently returned the test harness's own box whenever an item was not sized — so `Card 3 == 500` could pass for entirely the wrong reason. It now anchors on FlexWrapItem, and a negative-control test pins that down. New coverage: the three nested shapes that crashed, an align-items:flex-start falsification control, an assertion that no IntrinsicHeight exists, percentage basis/min-width, max-width capping, and the ` ` item. Also covered: the auto-basis path (no `flex-basis`, no `width`), which is the only route into `getMaxIntrinsicWidth` from inside computeDryLayout, and nowrap `flex: 1` geometry — the resolver's new implied `0%` basis had no nowrap assertion before, so the suite could not tell "unchanged" from "untested". Suite: 2374 root+core (was 2355), 28 golden, 73 html, 48 epub, 44 example. --- CHANGELOG.md | 13 +- doc/CSS_PROPERTIES_MATRIX.md | 10 +- lib/hyper_render.dart | 3 + .../lib/hyper_render_core.dart | 1 + .../lib/src/model/computed_style.dart | 16 + .../lib/src/style/resolver.dart | 49 +- .../src/widgets/flex_container_widget.dart | 355 +++--------- .../lib/src/widgets/hyper_render_widget.dart | 35 +- .../lib/src/widgets/render_flex_wrap.dart | 546 ++++++++++++++++++ test/docs_matrix_sync_test.dart | 4 + test/style/flex_wrap_test.dart | 362 ++++++++++-- 11 files changed, 1024 insertions(+), 370 deletions(-) create mode 100644 packages/hyper_render_core/lib/src/widgets/render_flex_wrap.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 688c0bd..09755da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,16 @@ ### 🐛 Fixes -- **`display:flex; flex-wrap:wrap` with flex children crashed the frame** ([#15](https://github.com/brewkits/hyper_render/issues/15)): a wrapping flex container was mapped to Flutter's `Wrap`, but its items were still wrapped in `FlexItemWidget`, which emits `Expanded`/`Flexible`. `Wrap` provides `WrapParentData`, so Flutter threw *"Incorrect use of ParentDataWidget … wants to apply ParentData of type FlexParentData"* and cascaded into `RenderBox was not laid out` / `child.hasSize is not true` for the rest of the document. Wrapping flex is now laid out arithmetically — items are packed into lines by their base size and each line's free space is distributed in proportion to `flex-grow` — and emitted as a `Column` of `Row`s, so no flex parent data ever reaches a `Wrap`. Shapes that cannot be sized at build time (`flex-direction: column`, `row-reverse`/`wrap-reverse`, unbounded width, items with no knowable base) still fall back to `Wrap`, but with every `Expanded`/`Flexible` stripped and replaced by `flex-basis`/`min-width`/`max-width` sizing. -- **CSS `align-items: baseline` asserted on every flex container**: `CrossAxisAlignment.baseline` was handed to `Row`/`Column` without a `textBaseline`, tripping *"textBaseline is required if you specify the crossAxisAlignment with CrossAxisAlignment.baseline"*. Both paths now pass `TextBaseline.alphabetic`. -- **`flex-basis`, `min-width` and `max-width` were parsed but never applied to flex items**: `flex: 1 1 220px; min-width: 220px` sized from content instead of the declared basis. All three now participate in wrapping-flex sizing. +- **`display:flex; flex-wrap:wrap` with flex children crashed the frame** ([#15](https://github.com/brewkits/hyper_render/issues/15)): a wrapping flex container was mapped to Flutter's `Wrap`, but its items were still wrapped in `FlexItemWidget`, which emits `Expanded`/`Flexible`. `Wrap` provides `WrapParentData`, so Flutter threw *"Incorrect use of ParentDataWidget … wants to apply ParentData of type FlexParentData"* and cascaded into `RenderBox was not laid out` / `child.hasSize is not true`. +- **Horizontal wrapping flex is now a real render object, `RenderFlexWrap`.** Neither Flutter built-in can express CSS here: `Wrap` has no `flex-grow` and rejects flex parent data, while a `LayoutBuilder`-driven `Column` of `Row`s cannot answer intrinsic or dry-layout queries — and CSS's default `align-items: stretch` puts an `IntrinsicHeight` above every nested flex container, so *nested* wrapping flex crashed with `LayoutBuilder does not support returning intrinsic dimensions` plus ~34 cascading layout errors. `RenderFlexWrap` packs lines, distributes free space by `flex-grow`, implements intrinsics, dry layout, painting and hit-testing, and adds no `IntrinsicHeight` at all. `flex-direction: column` + wrap still uses `Wrap` (its main axis is height, which is unbounded), with all flex parent data stripped. +- **`flex-basis`, `min-width` and `max-width` were parsed but never applied to flex items.** `flex: 0 0 50%` and a bare `min-width: 300px` both collapsed the item to its content width (~32px in a 500px container) instead of 250px / 300px. All three now drive wrapping-flex sizing, including their percentage forms — `min-width` accepts `%` for the first time, and the `flex: 1` / `flex: 1 1` shorthands now correctly imply CSS's `0%` basis rather than `auto`. +- **CSS `align-items: baseline` asserted on every flex container**: `CrossAxisAlignment.baseline` was handed to `Row`/`Column` without a `textBaseline`. Both now pass `TextBaseline.alphabetic`. +- **An ` `-only flex item disappeared entirely**: `_buildFlexChild` used `String.trim().isEmpty` to detect insignificant whitespace, but Dart follows Unicode (U+00A0 is whitespace) while CSS Text Level 3 does not. It now uses `isCssWhitespaceOnly`, and trims only CSS whitespace so a leading/trailing ` ` survives. + +### 📝 Documentation + +- `CSS_PROPERTIES_MATRIX.md`: `align-content` corrected from ✅ to ❌ for **both** flex and grid — it is parsed into `ComputedStyle` and read by nothing in the render path. `test/docs_matrix_sync_test.dart` now guards it against regressing. +- `flex-basis` documented as ⚠️: it drives wrapping (row) containers only; the `nowrap` path still sizes from content. ## 1.8.0 diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md index e349a17..9e68e41 100644 --- a/doc/CSS_PROPERTIES_MATRIX.md +++ b/doc/CSS_PROPERTIES_MATRIX.md @@ -26,7 +26,7 @@ This document lists CSS property support in HyperRender. |----------|--------|------------------|-------| | `width` | ✅ | px, %, auto | Constrains a block's content width (text wraps inside it), on replaced elements too. `%` resolves against the containing block | | `height` | ✅ | px, auto | Absolute px on replaced elements. `%` height not supported (needs a deferred-height model) | -| `min-width` | ✅ | px, % | Applied to block content width; wins over `max-width` per CSS | +| `min-width` | ✅ | px, % | Applied to block content width and to wrapping-flex item sizing; wins over `max-width` per CSS | | `max-width` | ✅ | px, % | Constrains a block's content width (text wraps inside it) | | `min-height` | ❌ | — | Parsed into `ComputedStyle.minHeight`, then read by nothing in the render path. Found by `test/flagship_execution_audit_test.dart` | | `max-height` | ❌ | — | Parsed into `ComputedStyle.maxHeight`, then read by nothing in the render path. Found by `test/flagship_execution_audit_test.dart` | @@ -72,14 +72,14 @@ This document lists CSS property support in HyperRender. | Property | Status | Supported Values | Notes | |----------|--------|------------------|-------| | `flex-direction` | ✅ | row, column, row-reverse, column-reverse | | -| `flex-wrap` | ✅ | nowrap, wrap, wrap-reverse | `wrap` on a `row` container packs items into lines and distributes free space by `flex-grow`; `wrap-reverse` and `flex-direction: column` fall back to Flutter `Wrap` (items keep their base size, no growth) | +| `flex-wrap` | ✅ | nowrap, wrap, wrap-reverse | `wrap`/`wrap-reverse` on a `row` container run on `RenderFlexWrap`: real line packing plus free-space distribution by `flex-grow`. `flex-direction: column` + wrap falls back to Flutter `Wrap` (items keep their base size, no growth) — the main axis there is height, which is unbounded | | `flex` | ✅ | \ \ \ | Shorthand | | `flex-grow` | ✅ | number | | | `flex-shrink` | ✅ | number | | -| `flex-basis` | ⚠️ | px | Applied on `flex-wrap: wrap` containers only — the `nowrap` Row/Column path sizes from content. `%` and `auto` are not parsed and resolve to 0 (a growable item then splits the line evenly, matching a browser's `flex: 1`) | +| `flex-basis` | ⚠️ | px, %, auto | Applied on `flex-wrap: wrap` (row) containers only — the `nowrap` Row/Column path still sizes from content. The `flex: 1` / `flex: 1 1` shorthands correctly imply a `0%` basis | | `justify-content` | ✅ | flex-start, center, flex-end, space-between, space-around | | | `align-items` | ✅ | flex-start, center, flex-end, stretch, baseline | | -| `align-content` | ✅ | flex-start, center, flex-end, space-between, space-around | | +| `align-content` | ❌ | — | Parsed but not applied: lines always stack from the cross-axis start. Verified by execution — `align-content:center` on a fixed-height wrapping container leaves the first line at offset 0 | | `align-self` | ✅ | auto, flex-start, center, flex-end, stretch | | | `gap` | ✅ | px | Row and column gap | | `row-gap` | ✅ | px | | @@ -100,7 +100,7 @@ This document lists CSS property support in HyperRender. | `gap` / `row-gap` / `column-gap` | ✅ | px | Full support | | `grid-auto-flow` | ⚠️ | row | Column/dense not yet implemented | | `justify-items` | ✅ | flex-start, center, flex-end, stretch | | -| `align-content` | ✅ | flex-start, center, flex-end, stretch | | +| `align-content` | ❌ | — | Parsed into `ComputedStyle.alignContent` and read by nothing in the render path (grid included) — rows always stack from the cross-axis start | --- diff --git a/lib/hyper_render.dart b/lib/hyper_render.dart index a468a98..7b4708b 100644 --- a/lib/hyper_render.dart +++ b/lib/hyper_render.dart @@ -126,6 +126,9 @@ export 'package:hyper_render_core/hyper_render_core.dart' // Container widgets FlexContainerWidget, FlexItemWidget, + FlexWrapItem, + FlexWrapLayout, + RenderFlexWrap, GridItem, HyperDetailsWidget, ErrorBoundaryWidget, diff --git a/packages/hyper_render_core/lib/hyper_render_core.dart b/packages/hyper_render_core/lib/hyper_render_core.dart index 0ead867..944b6a1 100644 --- a/packages/hyper_render_core/lib/hyper_render_core.dart +++ b/packages/hyper_render_core/lib/hyper_render_core.dart @@ -94,5 +94,6 @@ export 'src/widgets/error_boundary_widget.dart'; export 'src/widgets/hyper_error_widget.dart'; export 'src/widgets/loading_skeleton.dart'; export 'src/widgets/flex_container_widget.dart'; +export 'src/widgets/render_flex_wrap.dart'; export 'src/widgets/grid_container_widget.dart'; export 'src/widgets/hyper_details_widget.dart'; diff --git a/packages/hyper_render_core/lib/src/model/computed_style.dart b/packages/hyper_render_core/lib/src/model/computed_style.dart index 3f99eef..cb4c565 100644 --- a/packages/hyper_render_core/lib/src/model/computed_style.dart +++ b/packages/hyper_render_core/lib/src/model/computed_style.dart @@ -410,6 +410,16 @@ class ComputedStyle { double? widthPercent; double? maxWidthPercent; + /// CSS `min-width` expressed as a percentage of the containing block's + /// content width — `50%` is stored as `0.5`. Resolved at layout, like + /// [widthPercent]. + double? minWidthPercent; + + /// CSS `flex-basis` expressed as a percentage of the flex container's + /// content width — `50%` is stored as `0.5`, and the `flex: 1` shorthand's + /// implied `0%` as `0`. Resolved at layout by `RenderFlexWrap`. + double? flexBasisPercent; + /// CSS margin (collapsed margins handled in layout) EdgeInsets margin; @@ -740,6 +750,8 @@ class ComputedStyle { this.maxHeight, this.widthPercent, this.maxWidthPercent, + this.minWidthPercent, + this.flexBasisPercent, this.margin = EdgeInsets.zero, this.padding = EdgeInsets.zero, this.borderWidth = EdgeInsets.zero, @@ -918,6 +930,8 @@ class ComputedStyle { double? maxHeight, double? widthPercent, double? maxWidthPercent, + double? minWidthPercent, + double? flexBasisPercent, EdgeInsets? margin, EdgeInsets? padding, EdgeInsets? borderWidth, @@ -1027,6 +1041,8 @@ class ComputedStyle { maxHeight: maxHeight ?? this.maxHeight, widthPercent: widthPercent ?? this.widthPercent, maxWidthPercent: maxWidthPercent ?? this.maxWidthPercent, + minWidthPercent: minWidthPercent ?? this.minWidthPercent, + flexBasisPercent: flexBasisPercent ?? this.flexBasisPercent, margin: margin ?? this.margin, padding: padding ?? this.padding, borderWidth: borderWidth ?? this.borderWidth, diff --git a/packages/hyper_render_core/lib/src/style/resolver.dart b/packages/hyper_render_core/lib/src/style/resolver.dart index 3c0064a..29ff8f1 100644 --- a/packages/hyper_render_core/lib/src/style/resolver.dart +++ b/packages/hyper_render_core/lib/src/style/resolver.dart @@ -1730,7 +1730,12 @@ class StyleResolver { style.flexShrink = double.tryParse(parts[1]) ?? 1; } if (parts.length > 2) { - style.flexBasis = _parseLength(parts[2]); + _applyFlexBasis(style, parts[2]); + } else { + // CSS: the one- and two-value forms set flex-basis to 0%, NOT auto. + // `flex: 1` therefore sizes purely from distributed free space. + style.flexBasis = null; + style.flexBasisPercent = 0; } style.markExplicitlySet('flex'); } @@ -1753,9 +1758,7 @@ class StyleResolver { break; case 'flex-basis': - final flexBasis = _parseLength(value); - if (flexBasis != null) { - style.flexBasis = flexBasis; + if (_applyFlexBasis(style, value)) { style.markExplicitlySet('flex-basis'); } break; @@ -2076,10 +2079,16 @@ class StyleResolver { break; case 'min-width': - final length = _parseLength(value); - if (length != null) { - style.minWidth = length; + final minPct = _parsePercent(value.trim().toLowerCase()); + if (minPct != null) { + style.minWidthPercent = minPct; // fraction 0–1, resolved at layout style.markExplicitlySet('min-width'); + } else { + final length = _parseLength(value); + if (length != null) { + style.minWidth = length; + style.markExplicitlySet('min-width'); + } } break; @@ -3473,6 +3482,32 @@ class StyleResolver { } /// Parse CSS length value (px, pt, em, etc.) + /// Applies a `flex-basis` value (`auto`, a percentage, or a length). + /// + /// Returns false for values that resolve to nothing usable. `auto` clears + /// both fields so the layout falls back to the item's max-content width. + bool _applyFlexBasis(ComputedStyle style, String value) { + final v = value.trim().toLowerCase(); + if (v == 'auto' || v == 'content') { + style.flexBasis = null; + style.flexBasisPercent = null; + return true; + } + final pct = _parsePercent(v); + if (pct != null) { + style.flexBasis = null; + style.flexBasisPercent = pct; + return true; + } + final len = _parseLength(v); + if (len != null) { + style.flexBasis = len; + style.flexBasisPercent = null; + return true; + } + return false; + } + double? _parseLength(String value) { value = value.trim().toLowerCase(); diff --git a/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart b/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart index a78971e..76ca662 100644 --- a/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart +++ b/packages/hyper_render_core/lib/src/widgets/flex_container_widget.dart @@ -1,9 +1,8 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import '../model/computed_style.dart'; import '../model/node.dart'; +import 'render_flex_wrap.dart'; import 'css_border.dart'; /// Widget that renders a flex container (display: flex) @@ -150,47 +149,39 @@ class FlexContainerWidget extends StatelessWidget { } else { // Wrapping flex (`flex-wrap: wrap` / `wrap-reverse`). // - // Flutter's `Wrap` provides `WrapParentData`, so an `Expanded`/`Flexible` - // emitted by [FlexItemWidget] underneath it trips - // "Incorrect use of ParentDataWidget" and cascades into a broken frame - // (issue #15). Two strategies, both of which guarantee that no flex - // parent data is ever attached to a `Wrap` child: + // Horizontal wrap goes to [FlexWrapLayout], a real render object. Neither + // Flutter built-in can express CSS here: `Wrap` has no `flex-grow` and + // rejects `Expanded`/`Flexible` children (`WrapParentData` vs + // `FlexParentData`, issue #15), while a `LayoutBuilder`-driven Column of + // Rows cannot answer intrinsic or dry-layout queries — and CSS's default + // `align-items: stretch` puts an `IntrinsicHeight` above every nested + // flex container, so that shape crashed on contact. // - // 1. `_buildFlexLines` — a real CSS wrapping-flex layout (line packing + - // free-space distribution) emitted as a Column of Rows. Rows are - // `Flex`es, and widths are resolved arithmetically, so no - // `Expanded`/`Flexible` is needed at all. - // 2. `_buildStrippedWrap` — fallback for shapes strategy 1 cannot size at - // build time (column wrap, unknown-size items, unbounded width): a - // plain `Wrap` whose `FlexItemWidget` children are replaced by - // `flex-basis`/`min-width`/`max-width` sizing widgets. - flexWidget = LayoutBuilder( - builder: (context, constraints) { - final lines = _buildFlexLines( - constraints: constraints, - axis: axis, - isReverse: isReverse, - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - containerStyle: style, - ); - if (lines != null) return lines; - - final bool reverseWrap = style.flexWrap == FlexWrap.wrapReverse; - return Wrap( - direction: axis, - alignment: wrapAlignment, - crossAxisAlignment: wrapCrossAlignment, - spacing: mainAxisSpacing, - runSpacing: crossAxisSpacing, - verticalDirection: - reverseWrap ? VerticalDirection.up : VerticalDirection.down, - children: _buildStrippedWrapChildren(axis, constraints), - ); - }, - ); + // Vertical wrap keeps `Wrap`: packing lines along an unbounded cross axis + // (width) is well defined, but the main axis (height) is not. + if (axis == Axis.horizontal) { + flexWidget = FlexWrapLayout( + spacing: mainAxisSpacing, + runSpacing: crossAxisSpacing, + justifyContent: style.justifyContent, + alignItems: style.alignItems, + reverseItems: isReverse, + reverseRuns: style.flexWrap == FlexWrap.wrapReverse, + children: _asFlexWrapItems(), + ); + } else { + final bool reverseWrap = style.flexWrap == FlexWrap.wrapReverse; + flexWidget = Wrap( + direction: axis, + alignment: wrapAlignment, + crossAxisAlignment: wrapCrossAlignment, + spacing: mainAxisSpacing, + runSpacing: crossAxisSpacing, + verticalDirection: + reverseWrap ? VerticalDirection.up : VerticalDirection.down, + children: _buildVerticalWrapChildren(), + ); + } } // Apply container styling (padding, margin, background, border) @@ -292,236 +283,46 @@ class FlexContainerWidget extends StatelessWidget { } } - /// Builds a wrapping flex container as a `Column` of `Row`s, resolving CSS - /// `flex-basis` / `flex-grow` / `flex-shrink` / `min-width` / `max-width` - /// arithmetically. + /// Wraps each child in a [FlexWrapItem] so [RenderFlexWrap] can read its CSS. /// - /// Returns `null` when the container's shape cannot be resolved at build - /// time, in which case the caller falls back to a plain (flex-parent-data - /// free) `Wrap`. Bailing out covers: - /// * `flex-direction: column*` — the cross axis is height, which is - /// unbounded here, so lines cannot be packed; - /// * `row-reverse` / `wrap-reverse` — ordering is left to `Wrap`; - /// * an unbounded/degenerate main-axis extent; - /// * a child that is not a [FlexItemWidget], or one whose base size is not - /// knowable at build time (no `flex-basis`/`width`/`min-width` and no - /// `flex-grow` to size it from free space). - Widget? _buildFlexLines({ - required BoxConstraints constraints, - required Axis axis, - required bool isReverse, - required double mainAxisSpacing, - required double crossAxisSpacing, - required MainAxisAlignment mainAxisAlignment, - required CrossAxisAlignment crossAxisAlignment, - required ComputedStyle containerStyle, - }) { - if (axis != Axis.horizontal || isReverse) return null; - if (containerStyle.flexWrap == FlexWrap.wrapReverse) return null; - if (children.isEmpty) return null; - - final double available = constraints.maxWidth; - if (!available.isFinite || available <= 0) return null; - - final items = <_ResolvedFlexItem>[]; - for (final child in children) { - if (child is! FlexItemWidget) return null; - final ComputedStyle s = child.style; - final double grow = s.flexGrow ?? 0; - final double shrink = s.flexShrink ?? 1; - // CSS `flex-basis: auto` falls back to `width`; an unparsed basis - // (`0%`, `auto`) resolves to null and is treated as 0 for growable items. - final double? explicitBase = s.flexBasis ?? s.width ?? s.minWidth; - if (explicitBase == null && grow <= 0) return null; - - final double minWidth = s.minWidth ?? 0; - final double maxWidth = s.maxWidth ?? double.infinity; - double base = explicitBase ?? 0; - base = base.clamp(minWidth, math.max(minWidth, maxWidth)); - base = base.clamp(0.0, available); - - items.add(_ResolvedFlexItem( - item: child, - base: base, - grow: grow, - shrink: shrink, - minWidth: math.min(minWidth, available), - maxWidth: maxWidth, - )); - } - - // Pack items into lines: an item starts a new line when it no longer fits - // in the remaining main-axis extent (gaps included). - final lines = >[]; - var current = <_ResolvedFlexItem>[]; - double currentExtent = 0; - for (final item in items) { - final double candidate = current.isEmpty - ? item.base - : currentExtent + mainAxisSpacing + item.base; - if (current.isNotEmpty && candidate > available + _epsilon) { - lines.add(current); - current = <_ResolvedFlexItem>[]; - currentExtent = 0; - } - currentExtent = current.isEmpty - ? item.base - : currentExtent + mainAxisSpacing + item.base; - current.add(item); - } - if (current.isNotEmpty) lines.add(current); - - final hasStretch = crossAxisAlignment == CrossAxisAlignment.stretch || - items.any((i) => i.item.style.alignSelf == AlignItems.stretch); - - final rows = []; - for (var l = 0; l < lines.length; l++) { - if (l > 0 && crossAxisSpacing > 0) { - rows.add(SizedBox(height: crossAxisSpacing)); - } - rows.add(_buildFlexLine( - line: lines[l], - available: available, - mainAxisSpacing: mainAxisSpacing, - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - useIntrinsicHeight: hasStretch, - )); - } - - if (rows.length == 1) return rows.first; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: rows, - ); - } - - /// Resolves one flex line's item widths and emits it as a [Row]. - /// - /// Widths are computed here rather than delegated to `Expanded`/`Flexible`, - /// because CSS distributes *free space* in proportion to `flex-grow` on top - /// of each item's base size, whereas `Expanded` divides the whole line. - Widget _buildFlexLine({ - required List<_ResolvedFlexItem> line, - required double available, - required double mainAxisSpacing, - required MainAxisAlignment mainAxisAlignment, - required CrossAxisAlignment crossAxisAlignment, - required bool useIntrinsicHeight, - }) { - final double gaps = mainAxisSpacing * (line.length - 1); - double totalBase = 0; - for (final i in line) { - totalBase += i.base; - } - final double free = available - gaps - totalBase; - - final widths = []; - if (free > _epsilon) { - double totalGrow = 0; - for (final i in line) { - totalGrow += i.grow; - } - for (final i in line) { - widths - .add(totalGrow > 0 ? i.base + free * (i.grow / totalGrow) : i.base); - } - } else if (free < -_epsilon) { - // Defensive only: line packing never emits a line wider than `available` - // (each base is clamped to it, and an item that would overflow starts a - // new line), so this branch is currently unreachable. It is kept so a - // future packing change degrades into CSS shrink rather than overflow. - double totalScaled = 0; - for (final i in line) { - totalScaled += i.shrink * i.base; - } - for (final i in line) { - widths.add(totalScaled > 0 - ? i.base + free * ((i.shrink * i.base) / totalScaled) - : i.base); - } - } else { - for (final i in line) { - widths.add(i.base); - } - } - - final rowChildren = []; - for (var i = 0; i < line.length; i++) { - if (i > 0 && mainAxisSpacing > 0) { - rowChildren.add(SizedBox(width: mainAxisSpacing)); + /// A child that never went through [FlexItemWidget] (no `flex-*` and no + /// `align-self`) still carries `min-width` / `max-width` / `width` that CSS + /// says must be honoured, so it is given its own style rather than dropped + /// through as an opaque box. + List _asFlexWrapItems() { + final nodeChildren = node.children; + final items = []; + for (var i = 0; i < children.length; i++) { + final child = children[i]; + if (child is FlexItemWidget) { + // Unflexed: RenderFlexWrap owns sizing, and `align-self` is applied by + // the render object's cross-axis placement rather than by an Align box. + items.add(FlexWrapItem( + style: child.style, + child: child.child, + )); + continue; } - final resolved = line[i]; - final double width = widths[i] - .clamp( - resolved.minWidth, math.max(resolved.minWidth, resolved.maxWidth)) - .clamp(0.0, available) - .toDouble(); - rowChildren.add(SizedBox( - width: width, - child: resolved.item.buildUnflexed(parentAxis: Axis.horizontal), - )); + // Fall back to the source node's style when the widget carries none. + final style = + i < nodeChildren.length ? nodeChildren[i].style : ComputedStyle(); + items.add(FlexWrapItem(style: style, child: child)); } - - Widget row = Row( - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - mainAxisSize: MainAxisSize.max, - textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline - ? TextBaseline.alphabetic - : null, - children: rowChildren, - ); - // No Expanded/Flexible is emitted above, so IntrinsicHeight is safe and is - // what bounds `align-self: stretch`'s SizedBox(height: infinity). - if (useIntrinsicHeight) row = IntrinsicHeight(child: row); - return row; + return items; } - /// Fallback path: the children a plain `Wrap` may legally receive. + /// Children a vertical `Wrap` may legally receive. /// - /// Every [FlexItemWidget] is replaced by its unflexed child plus explicit - /// sizing from `flex-basis` / `min-width` / `max-width`, so no `Expanded` or - /// `Flexible` is ever attached to `WrapParentData` (issue #15). - List _buildStrippedWrapChildren( - Axis axis, BoxConstraints constraints) { + /// Strips the `Expanded`/`Flexible` that [FlexItemWidget] would emit — `Wrap` + /// provides `WrapParentData` and asserts on flex parent data (issue #15) — + /// and replaces it with explicit `flex-basis` sizing on the main (vertical) + /// axis. + List _buildVerticalWrapChildren() { return children.map((child) { if (child is! FlexItemWidget) return child; - final ComputedStyle s = child.style; - // `align-self: stretch` builds SizedBox(height: infinity), which needs a - // bounded cross axis; a Wrap row does not provide one, so drop it here. - final Widget inner = child.buildUnflexed( - parentAxis: axis, - allowStretch: axis != Axis.horizontal, - ); - - if (axis != Axis.horizontal) { - final basis = s.flexBasis; - return basis != null ? SizedBox(height: basis, child: inner) : inner; - } - - final double bound = constraints.maxWidth; - final double limit = - bound.isFinite && bound > 0 ? bound : double.infinity; - final double minWidth = math.min(s.minWidth ?? 0, limit); - final double maxWidth = - math.max(minWidth, math.min(s.maxWidth ?? double.infinity, limit)); - - final basis = s.flexBasis; - if (basis != null) { - return SizedBox( - width: basis.clamp(minWidth, maxWidth), - child: inner, - ); - } - if (minWidth > 0 || maxWidth.isFinite) { - return ConstrainedBox( - constraints: BoxConstraints(minWidth: minWidth, maxWidth: maxWidth), - child: inner, - ); - } - return inner; + final inner = child.buildUnflexed(parentAxis: Axis.vertical); + final basis = child.style.flexBasis; + return basis != null ? SizedBox(height: basis, child: inner) : inner; }).toList(); } @@ -664,29 +465,3 @@ class FlexItemWidget extends StatelessWidget { } } } - -/// Tolerance for main-axis extent comparisons, so that a line whose items sum -/// to exactly the available width does not wrap because of float error. -const double _epsilon = 0.01; - -/// One flex item with its CSS sizing inputs resolved to pixels. -class _ResolvedFlexItem { - final FlexItemWidget item; - - /// Base (pre-growth) main-axis size: `flex-basis`, else `width`, else - /// `min-width`, else 0 for growable items. - final double base; - final double grow; - final double shrink; - final double minWidth; - final double maxWidth; - - const _ResolvedFlexItem({ - required this.item, - required this.base, - required this.grow, - required this.shrink, - required this.minWidth, - required this.maxWidth, - }); -} diff --git a/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart b/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart index da9dbaf..9ab5d91 100644 --- a/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart +++ b/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart @@ -12,6 +12,7 @@ import '../interfaces/code_highlighter.dart'; import '../interfaces/image_clipboard.dart'; import '../interfaces/node_plugin.dart'; import '../model/computed_style.dart'; +import '../util/html_whitespace.dart'; import '../model/node.dart'; import 'code_block_widget.dart'; import 'css_border.dart'; @@ -603,18 +604,16 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { for (final child in node.children) { final childWidget = _buildFlexChild(child, widgetBuilder); if (childWidget != null) { - // Wrap child with FlexItemWidget if it has flex properties - if (child.style.flexGrow != null || - child.style.flexShrink != null || - child.style.flexBasis != null || - child.style.alignSelf != null) { - flexChildren.add(FlexItemWidget( - style: child.style, - child: childWidget, - )); - } else { - flexChildren.add(childWidget); - } + // Always carry the item's ComputedStyle: the wrapping-flex renderer + // needs `min-width`/`max-width`/`width` even on items that declare no + // `flex-*` at all. For the nowrap Row/Column path this is behaviour- + // preserving — FlexItemWidget with no flex properties builds exactly + // the `Flexible(fit: loose)` (or bare child, in a Column) that the + // untagged branch used to build. + flexChildren.add(FlexItemWidget( + style: child.style, + child: childWidget, + )); } } @@ -625,6 +624,10 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { ); } + /// Leading/trailing CSS whitespace (U+00A0 deliberately excluded). + static final RegExp _cssEdgeWhitespace = + RegExp(r'^[ \t\n\r\f]+|[ \t\n\r\f]+$'); + /// Build a single flex child static TextAlign _toTextAlign(HyperTextAlign align) { switch (align) { @@ -655,8 +658,12 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { // If it's text content, convert to Text widget if (node.type == NodeType.text) { final textNode = node as TextNode; - final text = textNode.text.trim(); - if (text.isEmpty) return null; + // CSS Text Level 3 excludes U+00A0 from the whitespace that collapses, + // but Dart's String.trim() (and `\s`) follow Unicode and strip it — so + // `.trim().isEmpty` silently deletes an ` `-only flex item. + if (isCssWhitespaceOnly(textNode.text)) return null; + // Trim only CSS whitespace, so a leading/trailing ` ` survives. + final text = textNode.text.replaceAll(_cssEdgeWhitespace, ''); return Text( text, diff --git a/packages/hyper_render_core/lib/src/widgets/render_flex_wrap.dart b/packages/hyper_render_core/lib/src/widgets/render_flex_wrap.dart new file mode 100644 index 0000000..7086768 --- /dev/null +++ b/packages/hyper_render_core/lib/src/widgets/render_flex_wrap.dart @@ -0,0 +1,546 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import '../model/computed_style.dart'; + +/// Parent data carrying a wrapping-flex item's CSS box sizing inputs. +class FlexWrapParentData extends ContainerBoxParentData { + /// The item's resolved CSS. Null for a child that was not declared as a flex + /// item (it is then sized `auto`, i.e. from its max-content width). + ComputedStyle? style; +} + +/// Attaches a flex item's [ComputedStyle] to its slot in a [FlexWrapLayout]. +/// +/// The analogue of [Flexible] for [RenderFlexWrap] — but unlike `Flexible` it +/// carries no `FlexParentData`, so it is safe under any parent that accepts +/// [FlexWrapParentData]. +class FlexWrapItem extends ParentDataWidget { + const FlexWrapItem({super.key, required this.style, required super.child}); + + final ComputedStyle style; + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData! as FlexWrapParentData; + if (identical(parentData.style, style)) return; + parentData.style = style; + final parent = renderObject.parent; + if (parent is RenderObject) parent.markNeedsLayout(); + } + + @override + Type get debugTypicalAncestorWidgetClass => FlexWrapLayout; +} + +/// A CSS wrapping-flex container (`display: flex; flex-wrap: wrap`) on a +/// horizontal main axis. +/// +/// Exists because neither of Flutter's built-ins can express it: +/// * `Wrap` has no notion of `flex-grow`, and rejects `Expanded`/`Flexible` +/// children outright (`WrapParentData` vs `FlexParentData`); +/// * a `LayoutBuilder`-driven `Column` of `Row`s cannot answer intrinsic or +/// dry-layout queries, so it explodes the moment an ancestor asks for one +/// (`IntrinsicHeight`, which CSS's default `align-items: stretch` puts +/// above every nested flex container). +class FlexWrapLayout extends MultiChildRenderObjectWidget { + const FlexWrapLayout({ + super.key, + required super.children, + required this.spacing, + required this.runSpacing, + required this.justifyContent, + required this.alignItems, + this.reverseItems = false, + this.reverseRuns = false, + }); + + /// Main-axis gap between items on a line (`column-gap` / `gap`). + final double spacing; + + /// Cross-axis gap between lines (`row-gap` / `gap`). + final double runSpacing; + + final JustifyContent justifyContent; + final AlignItems alignItems; + + /// `flex-direction: row-reverse`. + final bool reverseItems; + + /// `flex-wrap: wrap-reverse`. + final bool reverseRuns; + + @override + RenderFlexWrap createRenderObject(BuildContext context) => RenderFlexWrap( + spacing: spacing, + runSpacing: runSpacing, + justifyContent: justifyContent, + alignItems: alignItems, + reverseItems: reverseItems, + reverseRuns: reverseRuns, + ); + + @override + void updateRenderObject(BuildContext context, RenderFlexWrap renderObject) { + renderObject + ..spacing = spacing + ..runSpacing = runSpacing + ..justifyContent = justifyContent + ..alignItems = alignItems + ..reverseItems = reverseItems + ..reverseRuns = reverseRuns; + } +} + +/// One item with its CSS sizing inputs resolved against a known container width. +class _Item { + _Item({ + required this.child, + required this.base, + required this.min, + required this.max, + required this.grow, + required this.shrink, + required this.alignSelf, + }); + + final RenderBox child; + + /// Base (pre-growth) main-axis size, already clamped to [min]/[max]. + final double base; + final double min; + final double max; + final double grow; + final double shrink; + final AlignItems? alignSelf; + + /// Filled in by `_distribute`. + double width = 0; +} + +/// Renders a wrapping flex line box: packs items into lines by their base size, +/// then distributes each line's free space per `flex-grow` (or removes overflow +/// per `flex-shrink`). +class RenderFlexWrap extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + RenderFlexWrap({ + double spacing = 0, + double runSpacing = 0, + JustifyContent justifyContent = JustifyContent.flexStart, + AlignItems alignItems = AlignItems.stretch, + bool reverseItems = false, + bool reverseRuns = false, + }) : _spacing = spacing, + _runSpacing = runSpacing, + _justifyContent = justifyContent, + _alignItems = alignItems, + _reverseItems = reverseItems, + _reverseRuns = reverseRuns; + + double _spacing; + double get spacing => _spacing; + set spacing(double v) { + if (_spacing == v) return; + _spacing = v; + markNeedsLayout(); + } + + double _runSpacing; + double get runSpacing => _runSpacing; + set runSpacing(double v) { + if (_runSpacing == v) return; + _runSpacing = v; + markNeedsLayout(); + } + + JustifyContent _justifyContent; + JustifyContent get justifyContent => _justifyContent; + set justifyContent(JustifyContent v) { + if (_justifyContent == v) return; + _justifyContent = v; + markNeedsLayout(); + } + + AlignItems _alignItems; + AlignItems get alignItems => _alignItems; + set alignItems(AlignItems v) { + if (_alignItems == v) return; + _alignItems = v; + markNeedsLayout(); + } + + bool _reverseItems; + bool get reverseItems => _reverseItems; + set reverseItems(bool v) { + if (_reverseItems == v) return; + _reverseItems = v; + markNeedsLayout(); + } + + bool _reverseRuns; + bool get reverseRuns => _reverseRuns; + set reverseRuns(bool v) { + if (_reverseRuns == v) return; + _reverseRuns = v; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! FlexWrapParentData) { + child.parentData = FlexWrapParentData(); + } + } + + // ---------------------------------------------------------------- sizing -- + + static double _pct(double? fraction, double available) => + (fraction == null || !available.isFinite) + ? double.nan + : available * fraction; + + double _minOf(ComputedStyle? s, double available) { + if (s == null) return 0; + if (s.minWidth != null) return s.minWidth!; + final p = _pct(s.minWidthPercent, available); + return p.isNaN ? 0 : p; + } + + double _maxOf(ComputedStyle? s, double available) { + if (s == null) return double.infinity; + if (s.maxWidth != null) return s.maxWidth!; + final p = _pct(s.maxWidthPercent, available); + return p.isNaN ? double.infinity : p; + } + + /// CSS base size: `flex-basis`, else `width`, else `auto` (max-content). + double _baseOf(RenderBox child, ComputedStyle? s, double available) { + if (s != null) { + if (s.flexBasis != null) return s.flexBasis!; + final bp = _pct(s.flexBasisPercent, available); + if (!bp.isNaN) return bp; + if (s.width != null) return s.width!; + final wp = _pct(s.widthPercent, available); + if (!wp.isNaN) return wp; + } + return child.getMaxIntrinsicWidth(double.infinity); + } + + /// Resolves every child's sizing inputs against [available]. + List<_Item> _resolveItems(double available) { + final items = <_Item>[]; + var child = firstChild; + while (child != null) { + final pd = child.parentData! as FlexWrapParentData; + final s = pd.style; + final min = math.max(0.0, _minOf(s, available)); + final max = math.max(min, _maxOf(s, available)); + var base = _baseOf(child, s, available).clamp(min, max); + if (available.isFinite) base = base.clamp(0.0, available); + items.add(_Item( + child: child, + base: base.toDouble(), + min: available.isFinite ? math.min(min, available) : min, + max: max, + grow: s?.flexGrow ?? 0, + shrink: s?.flexShrink ?? 1, + alignSelf: s?.alignSelf, + )); + child = pd.nextSibling; + } + if (_reverseItems) return items.reversed.toList(); + return items; + } + + /// Packs items into lines that fit within [available]. + List> _packLines(List<_Item> items, double available) { + if (items.isEmpty) return const []; + if (!available.isFinite) return [items]; + + final lines = >[]; + var current = <_Item>[]; + var extent = 0.0; + for (final item in items) { + final candidate = + current.isEmpty ? item.base : extent + _spacing + item.base; + if (current.isNotEmpty && candidate > available + _epsilon) { + lines.add(current); + current = <_Item>[]; + extent = 0; + } + extent = current.isEmpty ? item.base : extent + _spacing + item.base; + current.add(item); + } + if (current.isNotEmpty) lines.add(current); + return lines; + } + + /// Resolves each item's final main-axis size on one line, writing [_Item.width]. + void _distribute(List<_Item> line, double available) { + final gaps = _spacing * (line.length - 1); + var totalBase = 0.0; + for (final i in line) { + totalBase += i.base; + } + + if (!available.isFinite) { + for (final i in line) { + i.width = i.base; + } + return; + } + + final free = available - gaps - totalBase; + if (free > _epsilon) { + var totalGrow = 0.0; + for (final i in line) { + totalGrow += i.grow; + } + for (final i in line) { + i.width = totalGrow > 0 ? i.base + free * (i.grow / totalGrow) : i.base; + } + } else if (free < -_epsilon) { + // Only reachable when a single item's own minimum overflows the line. + var totalScaled = 0.0; + for (final i in line) { + totalScaled += i.shrink * i.base; + } + for (final i in line) { + i.width = totalScaled > 0 + ? i.base + free * ((i.shrink * i.base) / totalScaled) + : i.base; + } + } else { + for (final i in line) { + i.width = i.base; + } + } + + for (final i in line) { + i.width = i.width.clamp(i.min, math.max(i.min, i.max)).toDouble(); + if (i.width < 0) i.width = 0; + } + } + + AlignItems _crossAlignFor(_Item item) => item.alignSelf ?? _alignItems; + + /// Leading offset and inter-item gap for `justify-content` on one line. + ({double leading, double between}) _justify(double free, int count) { + if (free <= _epsilon || count == 0) { + return (leading: 0, between: _spacing); + } + switch (_justifyContent) { + case JustifyContent.flexStart: + return (leading: 0, between: _spacing); + case JustifyContent.flexEnd: + return (leading: free, between: _spacing); + case JustifyContent.center: + return (leading: free / 2, between: _spacing); + case JustifyContent.spaceBetween: + return count < 2 + ? (leading: 0, between: _spacing) + : (leading: 0, between: _spacing + free / (count - 1)); + case JustifyContent.spaceAround: + return (leading: free / (2 * count), between: _spacing + free / count); + case JustifyContent.spaceEvenly: + return ( + leading: free / (count + 1), + between: _spacing + free / (count + 1) + ); + } + } + + // ---------------------------------------------------------------- layout -- + + @override + void performLayout() { + final available = constraints.maxWidth; + final items = _resolveItems(available); + final lines = _packLines(items, available); + + if (lines.isEmpty) { + size = constraints.constrain(Size.zero); + return; + } + + final lineHeights = []; + var widest = 0.0; + + for (final line in lines) { + _distribute(line, available); + + var lineHeight = 0.0; + var lineWidth = _spacing * (line.length - 1); + for (final item in line) { + item.child.layout( + BoxConstraints(minWidth: item.width, maxWidth: item.width), + parentUsesSize: true, + ); + lineHeight = math.max(lineHeight, item.child.size.height); + lineWidth += item.width; + } + + // Second pass, only for the items that actually stretch. + for (final item in line) { + if (_crossAlignFor(item) != AlignItems.stretch) continue; + if ((item.child.size.height - lineHeight).abs() < _epsilon) continue; + item.child.layout( + BoxConstraints.tightFor(width: item.width, height: lineHeight), + parentUsesSize: true, + ); + } + + lineHeights.add(lineHeight); + widest = math.max(widest, lineWidth); + } + + var totalHeight = _runSpacing * (lines.length - 1); + for (final h in lineHeights) { + totalHeight += h; + } + + size = constraints.constrain( + Size(available.isFinite ? available : widest, totalHeight), + ); + + // Position. + var y = 0.0; + final order = _reverseRuns + ? List.generate(lines.length, (i) => lines.length - 1 - i) + : List.generate(lines.length, (i) => i); + for (final li in order) { + final line = lines[li]; + final lineHeight = lineHeights[li]; + final width = available.isFinite ? available : widest; + + var used = _spacing * (line.length - 1); + for (final item in line) { + used += item.width; + } + final placement = _justify(width - used, line.length); + + var x = placement.leading; + for (final item in line) { + final pd = item.child.parentData! as FlexWrapParentData; + final h = item.child.size.height; + final double dy; + switch (_crossAlignFor(item)) { + case AlignItems.center: + dy = y + (lineHeight - h) / 2; + case AlignItems.flexEnd: + dy = y + (lineHeight - h); + case AlignItems.stretch: + case AlignItems.flexStart: + // `baseline` needs font metrics the box model does not carry here; + // flex-start is the documented approximation (see CSS matrix). + case AlignItems.baseline: + dy = y; + } + pd.offset = Offset(x, dy); + x += item.width + placement.between; + } + y += lineHeight + _runSpacing; + } + } + + // ------------------------------------------------------------ dry layout -- + + @override + Size computeDryLayout(BoxConstraints constraints) { + final available = constraints.maxWidth; + final items = _resolveItems(available); + final lines = _packLines(items, available); + if (lines.isEmpty) return constraints.constrain(Size.zero); + + var total = _runSpacing * (lines.length - 1); + var widest = 0.0; + for (final line in lines) { + _distribute(line, available); + var lineHeight = 0.0; + var lineWidth = _spacing * (line.length - 1); + for (final item in line) { + final s = item.child.getDryLayout( + BoxConstraints(minWidth: item.width, maxWidth: item.width)); + lineHeight = math.max(lineHeight, s.height); + lineWidth += item.width; + } + total += lineHeight; + widest = math.max(widest, lineWidth); + } + return constraints + .constrain(Size(available.isFinite ? available : widest, total)); + } + + // ------------------------------------------------------------- intrinsics -- + + @override + double computeMaxIntrinsicWidth(double height) { + // Max-content: everything on one line at its base size. + final items = _resolveItems(double.infinity); + if (items.isEmpty) return 0; + var total = _spacing * (items.length - 1); + for (final i in items) { + total += i.base; + } + return total; + } + + @override + double computeMinIntrinsicWidth(double height) { + // Min-content: the widest single item, since every item may take its own line. + var widest = 0.0; + var child = firstChild; + while (child != null) { + final pd = child.parentData! as FlexWrapParentData; + final s = pd.style; + final declared = s?.flexBasis ?? s?.width ?? s?.minWidth; + widest = math.max( + widest, + declared ?? child.getMinIntrinsicWidth(double.infinity), + ); + child = pd.nextSibling; + } + return widest; + } + + double _intrinsicHeightAt(double width) { + final items = _resolveItems(width); + final lines = _packLines(items, width); + if (lines.isEmpty) return 0; + var total = _runSpacing * (lines.length - 1); + for (final line in lines) { + _distribute(line, width); + var lineHeight = 0.0; + for (final item in line) { + lineHeight = + math.max(lineHeight, item.child.getMaxIntrinsicHeight(item.width)); + } + total += lineHeight; + } + return total; + } + + @override + double computeMinIntrinsicHeight(double width) => _intrinsicHeightAt(width); + + @override + double computeMaxIntrinsicHeight(double width) => _intrinsicHeightAt(width); + + // ------------------------------------------------------------ paint / hit -- + + @override + void paint(PaintingContext context, Offset offset) => + defaultPaint(context, offset); + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + defaultHitTestChildren(result, position: position); +} + +/// Tolerance for main-axis extent comparisons, so a line whose items sum to +/// exactly the available width does not wrap because of float error. +const double _epsilon = 0.01; diff --git a/test/docs_matrix_sync_test.dart b/test/docs_matrix_sync_test.dart index d9be6fc..cfeeab0 100644 --- a/test/docs_matrix_sync_test.dart +++ b/test/docs_matrix_sync_test.dart @@ -54,6 +54,10 @@ const _mustNotBeFull = [ // Marked ✅ here until 2026-08-16; this row stops that recurring. 'min-height', 'max-height', + // Written by the resolver, read by nothing: `grep -rn alignContent` finds + // only ComputedStyle + resolver. Was marked ✅ for both flex and grid until + // 2026-09-07. + 'align-content', ]; /// Returns the status symbol (first char of the Status cell) for the first diff --git a/test/style/flex_wrap_test.dart b/test/style/flex_wrap_test.dart index 732f0a2..5a38e3b 100644 --- a/test/style/flex_wrap_test.dart +++ b/test/style/flex_wrap_test.dart @@ -2,15 +2,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hyper_render/hyper_render.dart'; -/// Regression tests for issue #15 — `display:flex; flex-wrap:wrap` with -/// children carrying a CSS `flex` shorthand used to emit -/// `Wrap → Expanded/Flexible`, which trips Flutter's -/// "Incorrect use of ParentDataWidget" assertion (`FlexParentData` applied to a -/// `RenderObject` set up for `WrapParentData`) and cascades into a broken -/// frame. +/// Regression tests for issue #15 — `display:flex; flex-wrap:wrap`. /// -/// The tests assert geometry, not merely the absence of an exception: a -/// "doesn't throw" test passes happily while the cards are sized wrongly. +/// Two distinct crashes are guarded here, because the first fix caused the +/// second: +/// +/// 1. Mapping a wrapping flex container to Flutter's `Wrap` while its items +/// were still wrapped in `FlexItemWidget` put `Expanded`/`Flexible` +/// (`FlexParentData`) under `WrapParentData` — the reported bug. +/// 2. Replacing that with a `LayoutBuilder`-driven Column of Rows made every +/// wrapping container unable to answer intrinsic queries. CSS's default +/// `align-items: stretch` puts an `IntrinsicHeight` above every nested +/// flex container, so *nested* wrapping flex died on contact with a much +/// larger cascade than the original bug. +/// +/// Both are now handled by `RenderFlexWrap`, a real render object. +/// +/// The helpers below deliberately anchor on [FlexWrapItem] — the box the +/// render object actually sizes. An earlier version of this file matched +/// `SizedBox` instead, which silently returned the *test harness's* own +/// `SizedBox` whenever the item was not sized, so assertions could pass for +/// the wrong reason. `cardWidth returns the item box, not the harness box` +/// below is the negative control that pins this down. void main() { Future pumpHtml( WidgetTester tester, @@ -30,9 +43,7 @@ void main() { await tester.pumpAndSettle(); } - /// Same, but with an unbounded height (the normal HyperViewer setting: a - /// scroll view). Any `SizedBox(height: infinity)` from `align-self: stretch` - /// must still be bounded by the layout, or `IntrinsicHeight` blows up. + /// Unbounded height (the normal setting: a scroll view). Future pumpScrolling( WidgetTester tester, String html, { @@ -50,13 +61,12 @@ void main() { await tester.pumpAndSettle(); } - /// Width of the box that directly sizes the card containing [text]. - double cardWidth(WidgetTester tester, String text) { - final box = find - .ancestor(of: find.text(text), matching: find.byType(SizedBox)) - .first; - return tester.getSize(box).width; - } + /// Width of the flex item box that `RenderFlexWrap` sized for [text]. + double cardWidth(WidgetTester tester, String text) => tester + .getSize(find + .ancestor(of: find.text(text), matching: find.byType(FlexWrapItem)) + .first) + .width; double cardTop(WidgetTester tester, String text) => tester.getTopLeft(find.text(text)).dy; @@ -93,7 +103,6 @@ void main() { // cards; the third moves to a new row. await pumpHtml(tester, cards, width: 500); expect(tester.takeException(), isNull); - expect(cardTop(tester, 'Card 1'), cardTop(tester, 'Card 2')); expect(cardTop(tester, 'Card 3'), greaterThan(cardTop(tester, 'Card 1'))); }); @@ -102,7 +111,6 @@ void main() { (tester) async { await pumpHtml(tester, cards, width: 500); expect(tester.takeException(), isNull); - // Row 1: (500 - 14) / 2 = 243 each. Row 2: card 3 grows to the full 500. expect(cardWidth(tester, 'Card 1'), closeTo(243, 0.5)); expect(cardWidth(tester, 'Card 2'), closeTo(243, 0.5)); @@ -111,10 +119,9 @@ void main() { testWidgets('all three fit on one row when the container is wide', (tester) async { - // 220*3 + 14*2 = 688 ≤ 800 → single row, each grows to (800-28)/3 = 257.33 + // 220*3 + 14*2 = 688 ≤ 800 → one row, each grows to (800-28)/3 = 257.33 await pumpHtml(tester, cards, width: 800); expect(tester.takeException(), isNull); - expect(cardTop(tester, 'Card 1'), cardTop(tester, 'Card 2')); expect(cardTop(tester, 'Card 2'), cardTop(tester, 'Card 3')); expect(cardWidth(tester, 'Card 1'), closeTo(772 / 3, 0.5)); @@ -135,8 +142,7 @@ void main() { testWidgets('bare `flex: 1` children share the row equally', (tester) async { - // `flex: 1` is `1 1 0%` — basis 0, so all items stay on one line and - // split the container evenly. + // `flex: 1` is `1 1 0%` — the shorthand's implied basis is 0, not auto. const html = '''
A
@@ -149,7 +155,7 @@ void main() { expect(cardWidth(tester, 'B'), closeTo(200, 0.5)); }); - testWidgets('mixed flex and non-flex children fall back to Wrap safely', + testWidgets('mixed flex and non-flex children do not assert', (tester) async { const html = '''
@@ -162,10 +168,6 @@ void main() { find.descendant(of: find.byType(Wrap), matching: find.byType(Expanded)), findsNothing, ); - expect( - find.descendant(of: find.byType(Wrap), matching: find.byType(Flexible)), - findsNothing, - ); }); testWidgets('flex-direction:column + wrap falls back without asserting', @@ -183,8 +185,7 @@ void main() { ); }); - testWidgets('wrap-reverse falls back to Wrap without asserting', - (tester) async { + testWidgets('wrap-reverse lays the runs out bottom-up', (tester) async { const html = '''
One
@@ -193,21 +194,27 @@ void main() {
'''; await pumpHtml(tester, html, width: 500); expect(tester.takeException(), isNull); - expect( - find.descendant(of: find.byType(Wrap), matching: find.byType(Expanded)), - findsNothing, - ); - expect( - find.descendant(of: find.byType(Wrap), matching: find.byType(Flexible)), - findsNothing, - ); + // "Three" is on the second run, which wrap-reverse puts on top. + expect(cardTop(tester, 'Three'), lessThan(cardTop(tester, 'One'))); + }); + + testWidgets('many wrapping cards render without cascading errors', + (tester) async { + final buf = + StringBuffer('
'); + for (var i = 1; i <= 12; i++) { + buf.write('
Card $i
'); + } + buf.write('
'); + await pumpHtml(tester, buf.toString(), width: 700); + expect(tester.takeException(), isNull); + expect(find.text('Card 12'), findsOneWidget); }); + }); + group('cross-axis alignment', () { testWidgets('align-self:stretch stays bounded under an unbounded height', (tester) async { - // The wrapping-flex rows are wrapped in IntrinsicHeight so that - // `align-self: stretch`'s SizedBox(height: infinity) has a finite bound - // even when the viewer sits in a scroll view. const html = '''
Tall
@@ -252,18 +259,271 @@ void main() { await pumpScrolling(tester, html, width: 500); expect(tester.takeException(), isNull); }); + }); - testWidgets('many wrapping cards render without cascading errors', + group('nested wrapping flex (the LayoutBuilder-intrinsics regression)', () { + // Every case here queries intrinsics across the wrapping container. A + // LayoutBuilder-based implementation throws "LayoutBuilder does not + // support returning intrinsic dimensions" plus ~34 cascading layout + // errors on each of them. + + testWidgets('inside a nowrap flex with the default align-items:stretch', (tester) async { - final buf = - StringBuffer('
'); - for (var i = 1; i <= 12; i++) { - buf.write('
Card $i
'); - } - buf.write('
'); - await pumpHtml(tester, buf.toString(), width: 700); + const html = ''' +
+
+
a
+
b
+
+
side
+
'''; + await pumpScrolling(tester, html, width: 500); expect(tester.takeException(), isNull); - expect(find.text('Card 12'), findsOneWidget); + }); + + testWidgets('as an align-self:stretch item', (tester) async { + const html = ''' +
+
+
a
+
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('wrapping flex directly inside wrapping flex', (tester) async { + const html = ''' +
+
+
x
+
y
+
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('explicit align-items:flex-start also stays clean', + (tester) async { + // The falsification control: with stretch opted out there is no + // IntrinsicHeight, so this shape survived even the broken build. It must + // keep working, or a "fix" that only special-cases stretch would pass. + const html = ''' +
+
+
a
+
b
+
+
side
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('no IntrinsicHeight is introduced at all', (tester) async { + // The old implementation added one IntrinsicHeight per flex line + // (align-items defaults to stretch, so effectively always) — an extra + // layout pass per row, and the thing that made the subtree + // intrinsic-hostile in the first place. + await pumpHtml(tester, cards, width: 500); + expect(find.byType(IntrinsicHeight), findsNothing); + }); + + testWidgets('inside a table cell and a list item', (tester) async { + const html = ''' +
+
+
t1
t2
+
+
+
  • +
    +
    l1
    l2
    +
    +
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('under an unbounded main axis (horizontal scroll)', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: HyperViewer(html: ''' +
+
u1
u2
+
'''), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); + + group('CSS sizing that used to be parsed but never applied', () { + testWidgets('percentage flex-basis resolves against the container', + (tester) async { + const html = ''' +
+
H1
+
H2
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'H1'), closeTo(250, 0.5)); + expect(cardWidth(tester, 'H2'), closeTo(250, 0.5)); + expect(cardTop(tester, 'H1'), cardTop(tester, 'H2')); + }); + + testWidgets('min-width alone (no flex shorthand) is honoured', + (tester) async { + const html = ''' +
+
MW
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'MW'), closeTo(300, 0.5)); + }); + + testWidgets('percentage min-width resolves against the container', + (tester) async { + const html = ''' +
+
PM
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'PM'), closeTo(300, 0.5)); + }); + + testWidgets('max-width caps a grown item', (tester) async { + const html = ''' +
+
Cap
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'Cap'), closeTo(150, 0.5)); + }); + + testWidgets('an ` `-only flex item is not swallowed', (tester) async { + // Dart's String.trim() treats U+00A0 as whitespace; CSS Text Level 3 + // does not. Trimming with it deleted the item outright. + const html = ''' +
+
 
+
B
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect(find.byType(FlexWrapItem), findsNWidgets(2)); + }); + }); + + group('auto basis (no flex-basis, no width)', () { + // Every other test declares an explicit basis, so `_baseOf`'s fallback to + // child.getMaxIntrinsicWidth — which also runs inside computeDryLayout and + // the intrinsic getters — would otherwise be unexercised. + + testWidgets('sizes from max-content and does not assert', (tester) async { + const html = ''' +
+
short
+
a much longer item label
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + expect(cardWidth(tester, 'short'), + lessThan(cardWidth(tester, 'a much longer item label'))); + }); + + testWidgets('survives an ancestor that forces intrinsics/dry layout', + (tester) async { + const html = ''' +
+
+
auto one
+
auto two
+
+
side
+
'''; + await pumpScrolling(tester, html, width: 500); + expect(tester.takeException(), isNull); + }); + + testWidgets('flex: 1 1 auto keeps an auto basis, unlike flex: 1', + (tester) async { + // `flex: 1` implies a 0% basis (items split evenly); the explicit + // three-value `auto` form must not. + const html = ''' +
+
tiny
+
a longer label
+
'''; + await pumpHtml(tester, html, width: 500); + expect(tester.takeException(), isNull); + // Both fit on one line, so free space is split evenly on top of two + // different bases — the wider base stays wider. + expect(cardTop(tester, 'tiny'), cardTop(tester, 'a longer label')); + expect(cardWidth(tester, 'tiny'), + lessThan(cardWidth(tester, 'a longer label'))); + }); + }); + + group('nowrap path is unaffected by the flex-basis shorthand change', () { + // The resolver now sets flexBasisPercent = 0 for `flex: 1`. FlexItemWidget + // reads only flexGrow/flexShrink/alignSelf, so nowrap geometry must be + // byte-identical — but nothing asserted that before, so it could not tell + // "unchanged" from "untested". + + testWidgets('flex: 1 siblings split a nowrap row evenly', (tester) async { + const html = ''' +
+
N1
+
N2
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect(tester.getTopLeft(find.text('N1')).dy, + tester.getTopLeft(find.text('N2')).dy); + // Expanded(flex: 1) each → the row splits in half. + expect(tester.getTopLeft(find.text('N2')).dx, closeTo(200, 1.0)); + }); + + testWidgets('unequal flex-grow still splits proportionally', + (tester) async { + const html = ''' +
+
P1
+
P2
+
'''; + await pumpHtml(tester, html, width: 400); + expect(tester.takeException(), isNull); + expect(tester.getTopLeft(find.text('P2')).dx, closeTo(100, 1.0)); + }); + }); + + group('test-helper integrity', () { + testWidgets('cardWidth returns the item box, not the harness box', + (tester) async { + // Negative control for the helper itself. A previous version matched + // `SizedBox`, so when no per-item box existed it silently returned the + // harness's SizedBox(width: 500) — and an assertion of "Card 3 == 500" + // passed for entirely the wrong reason. + await pumpHtml(tester, cards, width: 500); + // Card 1 is 243 wide inside a 500-wide harness: the helper cannot be + // reading the harness box. + expect(cardWidth(tester, 'Card 1'), isNot(closeTo(500, 0.5))); + expect(cardWidth(tester, 'Card 1'), closeTo(243, 0.5)); + // And it anchors on a box the render object owns. + expect(find.byType(FlexWrapItem), findsNWidgets(3)); }); }); } From 0edb467f79543b18cb14e2490e775c43205cc0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Mon, 7 Sep 2026 15:13:56 +0700 Subject: [PATCH 3/4] chore(release): bump hyper_render and hyper_render_core to 1.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor, not patch: the wrapping-flex work adds public API to core — RenderFlexWrap / FlexWrapLayout / FlexWrapItem / FlexWrapParentData, FlexItemWidget.buildUnflexed(), and ComputedStyle.minWidthPercent / flexBasisPercent — all backwards-compatible. Root's hyper_render_core constraint moves to ^1.9.0 as well, and that part is required rather than cosmetic: lib/hyper_render.dart now re-exports FlexWrapItem, FlexWrapLayout and RenderFlexWrap, which do not exist in core 1.8.0. Left at ^1.8.0, `flutter pub downgrade` would resolve a core this package cannot compile against — the same lower-bound defect v1.7.1 fixed to reach 160/160 on pub.dev. Only root and core are versioned here. The other six sub-packages are untouched by this work and their ^1.7.0 core constraints already admit 1.9.0. Not touched: pubspec_publish_ready.yaml, still declaring 1.7.1 / core ^1.7.0. No script references it and the 1.8.0 release skipped it too, so it appears vestigial — flagged rather than silently updated. Suite after the bump: 2374 root+core, 28 golden, 754 core-standalone. --- CHANGELOG.md | 2 +- README.md | 4 ++-- example/pubspec.lock | 4 ++-- packages/hyper_render_core/CHANGELOG.md | 16 ++++++++++++++++ packages/hyper_render_core/pubspec.yaml | 2 +- pubspec.yaml | 4 ++-- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09755da..27dc94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.9.0 ### 🐛 Fixes diff --git a/README.md b/README.md index 0425230..4843b6e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Already using `flutter_html`? You don't need to rewrite your widget tree or lear ```dart // 1. In your pubspec.yaml: // dependencies: -// hyper_render: ^1.8.0 +// hyper_render: ^1.9.0 // 2. In your Dart file — replace this single line: // ❌ import 'package:flutter_html/flutter_html.dart'; @@ -68,7 +68,7 @@ Html( ```yaml dependencies: - hyper_render: ^1.8.0 + hyper_render: ^1.9.0 ``` ```dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 5eb7999..91e96cb 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -350,14 +350,14 @@ packages: path: ".." relative: true source: path - version: "1.8.0" + version: "1.9.0" hyper_render_core: dependency: "direct main" description: path: "../packages/hyper_render_core" relative: true source: path - version: "1.8.0" + version: "1.9.0" hyper_render_epub: dependency: "direct main" description: diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index a820a94..18a133f 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog — hyper_render_core +## 1.9.0 + +### 🆕 New + +- **`RenderFlexWrap`** — a render object for CSS `display:flex; flex-wrap:wrap` on a horizontal main axis, with `FlexWrapLayout` (the widget) and `FlexWrapItem` (the per-item style carrier). It packs items into lines by base size, distributes each line's free space in proportion to `flex-grow`, clamps to `min-width`/`max-width`, and implements intrinsics, dry layout, painting and hit-testing. +- **`ComputedStyle.minWidthPercent` and `ComputedStyle.flexBasisPercent`** — percentage forms resolved at layout, following the existing `widthPercent`/`maxWidthPercent` pattern. +- **`FlexItemWidget.buildUnflexed()`** — builds an item with `align-self` applied but no `Expanded`/`Flexible` wrapper, for parents that cannot accept flex parent data. + +### 🐛 Fixes + +- **`flex-wrap: wrap` containers emitted `Expanded`/`Flexible` under Flutter's `Wrap`**, which provides `WrapParentData` — Flutter threw *"Incorrect use of ParentDataWidget"* and cascaded into `RenderBox was not laid out`. Horizontal wrapping flex no longer goes through `Wrap` at all; `flex-direction: column` + wrap still does, with all flex parent data stripped. +- **Wrapping flex could not be nested inside another flex container.** CSS's `align-items` default is `stretch`, which puts an `IntrinsicHeight` above every nested flex container — and the previous `LayoutBuilder`-based implementation could not answer intrinsic queries, so those shapes died with `LayoutBuilder does not support returning intrinsic dimensions` plus ~34 cascading errors. `RenderFlexWrap` answers them, and introduces no `IntrinsicHeight` of its own. +- **`flex-basis`, `min-width` and `max-width` were parsed but never applied to flex items.** In a 500px container, `flex: 0 0 50%` produced 32.5px instead of 250px and a bare `min-width: 300px` produced 32.5px instead of 300px. `min-width` now accepts `%`, `flex-basis` accepts `%` and `auto`, and the `flex: 1` / `flex: 1 1` shorthands correctly imply CSS's `0%` basis rather than leaving it unset. +- **`align-items: baseline` asserted on every flex container** — `CrossAxisAlignment.baseline` was passed without a `textBaseline`. Both the wrap and nowrap paths now pass `TextBaseline.alphabetic`. +- **An ` `-only flex item was dropped entirely.** `_buildFlexChild` used `String.trim().isEmpty`, but Dart follows Unicode (U+00A0 is whitespace) while CSS Text Level 3 excludes it. It now uses `isCssWhitespaceOnly` and trims only CSS whitespace, so an edge ` ` survives. + ## 1.8.0 - **AI & LLM Streaming Architecture Primitives**: diff --git a/packages/hyper_render_core/pubspec.yaml b/packages/hyper_render_core/pubspec.yaml index 0bc3592..02f1f1d 100644 --- a/packages/hyper_render_core/pubspec.yaml +++ b/packages/hyper_render_core/pubspec.yaml @@ -1,6 +1,6 @@ name: hyper_render_core description: Core engine for HyperRender. Universal Document Tree, single-RenderObject layout with CSS float, Flexbox, Grid, CJK typography, AI streaming, and crash-free text selection. -version: 1.8.0 +version: 1.9.0 homepage: https://github.com/brewkits/hyper_render repository: https://github.com/brewkits/hyper_render/tree/main/packages/hyper_render_core issue_tracker: https://github.com/brewkits/hyper_render/issues diff --git a/pubspec.yaml b/pubspec.yaml index f15f7ae..df9cf6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: hyper_render description: "The only Flutter HTML/Markdown renderer with CSS float layout, crash-free text selection at any document size, CJK Ruby typography, and AI/LLM token streaming." -version: 1.8.0 +version: 1.9.0 homepage: https://github.com/brewkits/hyper_render repository: https://github.com/brewkits/hyper_render issue_tracker: https://github.com/brewkits/hyper_render/issues @@ -45,7 +45,7 @@ dependencies: # hyper_render_html / _markdown / _highlight were declared but never imported # — pure install weight for every consumer. They remain separately published; # depend on them explicitly if you import them. - hyper_render_core: ^1.8.0 + hyper_render_core: ^1.9.0 # Fixed version constraints for pub.dev compliance flutter_highlight: ^0.7.0 From 62fcbefd6b35ce71e0f7936c1c79a7b591009e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Mon, 7 Sep 2026 15:31:21 +0700 Subject: [PATCH 4/4] docs(readme): de-over-claim Flexbox, refresh stale counts, bump install snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature matrix claimed "✅ Full" for Flexbox / Grid. It is not full, and issue #15's reporter cited that exact row as the reason the crash mattered. Replaced with what actually executes — wrapping flex on a custom RenderObject — plus a footnote naming the real gaps: `align-content` is parsed and read by nothing, `flex-basis` does not drive the `nowrap` path, and `flex-direction: column` + wrap packs lines without growth. The footnote links to CSS_PROPERTIES_MATRIX.md for per-property status. Two test counts were stale and contradicted each other: the header badge said "2 460+ tests" while the Architecture section said "1 646 passing tests" and "fuzz (43 cases)". Actual, measured: 2 495 passing (2374 root+core, 73 html, 48 epub) plus 28 golden, and the fuzz suite runs 339 cases. Install snippets go to ^1.9.0 in both published READMEs — the root one and hyper_render_core's, which is its own pub.dev landing page. Not touched: the Benchmarks table still carries competitor timings and a "Scroll FPS 60" claim I have no measurements to confirm or replace. Flagged rather than rewritten, since inventing replacement numbers would repeat the problem this commit is fixing. --- README.md | 24 +++++++++++++++++++++--- packages/hyper_render_core/README.md | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4843b6e..3656b2e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Flutter](https://img.shields.io/badge/Flutter-3.10+-54C5F8.svg?logo=flutter)](https://flutter.dev) -**CSS float · crash-free selection · AI/LLM streaming · CJK/Furigana · `@keyframes` · 2 460+ tests · XSS-safe · Zero Gradle config** +**CSS float · crash-free selection · AI/LLM streaming · CJK/Furigana · `@keyframes` · 2 490+ tests · XSS-safe · Zero Gradle config**
@@ -127,7 +127,7 @@ HyperRender renders the whole document inside **one custom `RenderObject`**. CSS | RTL / BiDi (Arabic, Hebrew) | ⚠️ | ⚠️ | ✅ | | CSS Variables `var()` | ❌ | ❌ | ✅ | | CSS `@keyframes` animation | ❌ | ❌ | ✅ | -| Flexbox / Grid | ⚠️ Partial | ⚠️ Partial | ✅ Full | +| Flexbox / Grid | ⚠️ Partial | ⚠️ Partial | ✅ Wrapping flex on a custom RenderObject¹ | | `box-shadow` · `filter` | ❌ | ❌ | ✅ | | `list-style-type` (all 11 values) | ⚠️ disc only | ⚠️ disc only | ✅ | | `
` / `` | ❌ | ❌ | ✅ Interactive | @@ -136,6 +136,8 @@ HyperRender renders the whole document inside **one custom `RenderObject`**. CSS | Modular packages | ❌ monolith | ❌ monolith | ✅ opt-in add-ons | | Zero Gradle config | ✅ | ✅ | ✅ | +¹ `flex-wrap: wrap` on a row container performs real CSS line packing and distributes free space by `flex-grow`, honouring `flex-basis` / `min-width` / `max-width` including their `%` forms. Known gaps: `align-content` is not applied, `flex-basis` does not drive the `nowrap` path, and `flex-direction: column` + wrap packs lines without growth. Per-property status: [CSS_PROPERTIES_MATRIX.md](doc/CSS_PROPERTIES_MATRIX.md). + ### Benchmarks Measured on iPhone 13 + Pixel 6 with a 25 000-character article: @@ -194,6 +196,22 @@ Ruby copied to clipboard as `東京(とうきょう)`. ### CSS Variables · Flexbox · Grid +Responsive wrapping cards — `flex-wrap: wrap` packs items into lines and shares +each line's free space by `flex-grow`, so the same markup a browser gets works +here: + +```dart +HyperViewer(html: ''' +
+
Card 1
+
Card 2
+
Card 3
+
+''') +``` + +CSS custom properties and grid: + ```dart HyperViewer(html: '''