diff --git a/CHANGELOG.md b/CHANGELOG.md index 475de847..20401bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. --- +## [Unreleased] + +### Fixed + +- **`max-h-*` and `max-w-*` now apply to an `h-full` element.** Both were discarded, by the same mechanism in two places: the cap arrived as a `ConstrainedBox` whose additional constraint `BoxConstraints.enforce` clamps into the incoming range, and the incoming range was already tight. `h-full max-h-[120px]` under a `ConstrainedBox(maxHeight: 400)` rendered 400 and now renders 120; `w-1/2 h-full max-w-[100px]` in a 300 pixel parent rendered 150 and now renders 100. A TIGHT parent still wins over `max-h-*`, which is correct rather than the same bug: a tight constraint is the parent stating an exact size. +- **`h-full` no longer throws under an `IntrinsicHeight`.** It resolved through a `LayoutBuilder`, which cannot answer an intrinsic query, so any `IntrinsicHeight` / `IntrinsicWidth` above it asserted `LayoutBuilder does not support returning intrinsic dimensions`. The limitation was documented on five surfaces with an escape hatch ("use explicit `h-*` instead") rather than fixed. `h-full` is now the `WindFullHeightBox` render object, which answers intrinsics by forwarding to its child, so it renders under an `IntrinsicHeight`, in a `Table` cell and in an `items-stretch` grid cell, and matches the tallest sibling rather than reporting the screen height. `grid` is the one remaining `LayoutBuilder` path. + +### Changed + +- **`h-full` resolves at the render layer instead of through a `LayoutBuilder`.** The question it asks ("is the incoming height bounded") is only answerable during layout, and a `LayoutBuilder` was the widget-layer way to ask it; a `LayoutBuilder` also defers its whole subtree into a second layout pass. A consumer measured 1056 of them in one eight-scroll session against 258 widget builds, one per element carrying the class, re-run every frame. `WindFullHeightBox` reads `constraints` directly and needs neither. Behaviour is otherwise unchanged, pinned by twelve characterisation tests written against the old implementation first. + +### Quality + +- Twenty tests for `WindFullHeightBox`, none skipped, covering: bounded and unbounded, with and without a width factor, `max-w-*` and `max-h-*`, the outer box's own reported size, in-place updates through all four setters (including a screen-size change, which is what a rotation is), a childless element, and the dry-layout contract agreeing with the size actually laid out. Line coverage 94.5% to 95.2%. Every line of the new file is covered, including both null-child branches: a childless `WDiv` carrying only `h-full` builds no core structure and reaches the box with a null child, so an earlier `coverage:ignore` on those lines rested on a false premise. + ## [1.5.1] - 2026-09-07 ### Fixed diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index a49d5d58..80d48217 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -212,7 +212,7 @@ WDiv(className: 'flex items-center h-20') > **Column cross-axis stretch (default AND explicit `items-stretch`).** A `flex flex-col` with no explicit `items-*` token, OR with an explicit `items-stretch`, stretches each `WDiv`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width, matching CSS `align-items: stretch`. Explicit `items-stretch` therefore equalizes child widths (every eligible child fills the column), closing the asymmetry with `grid ... items-stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides (so `WAnchor > WDiv(w-32)` keeps 128 px; a `WAnchor > WDiv` with a self-flex token is excluded just as a direct self-flexing `WDiv` is); when the anchor wraps a `WText` or raw widget, the anchor stretches by policy so its tap surface fills the column. Left untouched: children with an explicit width (`w-*` / `min-w-*` / `max-w-*` / `w-full`, in any state/breakpoint variant), children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), `basis-*` children, absolute children, bare `WText` leaves, and raw Flutter widgets. `shrink-0` / `flex-none` children still stretch on the cross axis (`flex-shrink` is main-axis only, matching CSS). Add `items-start` / `items-center` / `items-end` to turn stretch off and let children size to content. Rows are never auto-stretched on the cross axis. When the column itself sits in an unbounded-width context (a bare `Row` slot, `UnconstrainedBox`, horizontal scroll), the stretch safely falls back to content-sized children instead of forcing an infinite width. -> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe). +> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`, and neither does `h-full` since it became the `WindFullHeightBox` render object: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe). > > ```dart > // Safe connector pattern: Stack + Positioned, no IntrinsicHeight diff --git a/doc/layout/grid.md b/doc/layout/grid.md index edb86ffa..27df0bf2 100644 --- a/doc/layout/grid.md +++ b/doc/layout/grid.md @@ -79,7 +79,7 @@ WDiv( ) ``` -The equal-height rows are laid out for real (each cell is measured with a loose height, then re-laid to at least the row's tallest via a **min** height, never a tight squeeze), NOT via `IntrinsicHeight`, so cells whose content is itself a `flex flex-col`, or that use `h-full` / `basis-*` (which carry a `LayoutBuilder`), stretch correctly instead of asserting `LayoutBuilder does not support returning intrinsic dimensions`. Because a cell is never forced below its own content height, a stretched cell also produces no residual `RenderFlex overflowed` warning (#141). +The equal-height rows are laid out for real (each cell is measured with a loose height, then re-laid to at least the row's tallest via a **min** height, never a tight squeeze), NOT via `IntrinsicHeight`, so cells whose content is itself a `flex flex-col`, or that use `h-full` / `basis-*` (neither of which carries a `LayoutBuilder` any more), stretch correctly instead of asserting `LayoutBuilder does not support returning intrinsic dimensions`. Because a cell is never forced below its own content height, a stretched cell also produces no residual `RenderFlex overflowed` warning (#141). ## Responsive diff --git a/doc/layout/sizing.md b/doc/layout/sizing.md index 56eceb9f..41d39f3a 100644 --- a/doc/layout/sizing.md +++ b/doc/layout/sizing.md @@ -177,30 +177,28 @@ Wrapping Wind content in `IntrinsicHeight` or `IntrinsicWidth` (or placing it in LayoutBuilder does not support returning intrinsic dimensions. ``` -**Why.** To resolve some sizes against the parent's real constraints, Wind introduces a `LayoutBuilder` (see `lib/src/widgets/w_div.dart`): `h-full` adds one around the cell only when the incoming height is unbounded, and a flex `basis-*` adds a single one around the surrounding flex when any direct child uses `basis-*`. `LayoutBuilder` runs during the layout phase, not the intrinsic-sizing phase, so an intrinsic-dimension query that passes through one of those `LayoutBuilder` paths asserts. This is a fundamental Flutter constraint (`LayoutBuilder` genuinely cannot answer intrinsics), not a Wind bug. Wind content that hits none of Wind's `LayoutBuilder` paths (`h-full` in an unbounded height, `basis-*`, or the column cross-axis stretch above) carries no `LayoutBuilder` and is safe to wrap. +**Why.** To resolve a size against the parent's real constraints, Wind used to introduce a `LayoutBuilder`, and a `LayoutBuilder` runs during the layout phase rather than the intrinsic-sizing phase, so any intrinsic query passing through one asserts. That is a Flutter constraint, not a Wind bug. -**What triggers it.** A `WDiv` (or any W-widget) whose `className` resolves `h-full` or a flex `basis-*`, anywhere inside the subtree you wrap in `IntrinsicHeight` / `IntrinsicWidth`. A `Row` of cards that you try to equalize with `IntrinsicHeight` is the common case. +**What still triggers it: `grid` only.** `grid-cols-*` composes a `Wrap` inside a `LayoutBuilder` (it needs the available width to compute a column width), so a `grid` anywhere inside the subtree you wrap in `IntrinsicHeight` / `IntrinsicWidth` still asserts. -**Escape hatches.** +**What no longer does.** `h-full` and flex `basis-*` are both intrinsic-safe now. `h-full` resolves through the `WindFullHeightBox` render object, `basis-*` through `WindMainExtentProvider`, and the column cross-axis stretch through `WindCrossStretch`; a render object answers intrinsic queries, so all three render under an `IntrinsicHeight`, in a `Table` cell, or in an `items-stretch` grid cell without throwing. If you carry an escape hatch for `h-full` from an earlier version, you can drop it. -- Prefer explicit sizing: give the cells a fixed `h-*` (or `size-*`) instead of `h-full` + `IntrinsicHeight`. -- Do not wrap Wind content that uses `h-full` / `basis-*` in `IntrinsicHeight` / `IntrinsicWidth`. +**Escape hatches, for the `grid` case that remains.** + +- Prefer explicit sizing: give the cells a fixed `h-*` (or `size-*`) instead of a `grid` + `IntrinsicHeight`. - For an equal-height row, use a `Stack` with a `Positioned(top: 0, bottom: 0)` element for the part that must fill, or reserve equal content so natural heights already match. -- Wind's own column cross-axis stretch (`items-stretch`, the `flex flex-col` default) equalizes width WITHOUT you wrapping it in `IntrinsicHeight`. It uses a `LayoutBuilder` + `SizedBox(width: double.infinity)` internally (gated on a bounded width), so treat it as a REPLACEMENT for `IntrinsicHeight`, not something to nest inside one. +- Wind's own `items-stretch` grid equalizes row heights with real layout rather than `IntrinsicHeight`, so reach for it INSTEAD of wrapping. ```dart -// Throws if a card resolves h-full / basis-* internally: +// Throws: the wrapped subtree contains a grid. IntrinsicHeight( - child: WDiv(className: 'flex flex-row', children: cards), + child: WDiv(className: 'grid grid-cols-2 gap-4', children: cards), ) -// Safe: explicit height on each cell, no IntrinsicHeight needed. +// Safe: items-stretch equalizes the row heights with real layout. WDiv( - className: 'flex flex-row gap-4', - children: [ - WDiv(className: 'h-40 ...', child: card1), - WDiv(className: 'h-40 ...', child: card2), - ], + className: 'grid grid-cols-2 gap-4 items-stretch', + children: cards, ) ``` diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index b6f1bac8..fa6e9a77 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -5,6 +5,7 @@ import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; import '../utils/wind_perf_counters.dart'; import 'wind_animation_wrapper.dart'; +import 'wind_full_height.dart'; import '../state/wind_anchor_state_provider.dart'; import '../state/wind_flex_overflow_scope.dart'; import '../state/wind_min_width_scroll_scope.dart'; @@ -624,7 +625,7 @@ class WDiv extends StatelessWidget { /// Builds the final `Row`/`Column` from `basis-*`-resolved children, applying /// the column smart cross-axis stretch or the row `Flexible` shrink wrap. /// Split out of [_buildFlexStructure] so it can run either directly or inside - /// the `basis-*` `LayoutBuilder`. + /// the `basis-*` extent provider. Widget _composeFlex({ required WindStyle styles, required bool isColumn, @@ -850,7 +851,7 @@ class WDiv extends StatelessWidget { } /// Whether any direct flex child carries a `basis-*` token. Cheap pre-check - /// (substring) so the common no-basis case skips the LayoutBuilder wrap. + /// (substring) so the common no-basis case skips the extent-provider wrap. static bool _anyChildHasBasis(List children) { for (final child in children) { final className = _extractChildClassName(child); @@ -1280,9 +1281,11 @@ class WDiv extends StatelessWidget { /// cells so columns stay aligned. /// /// Because the row uses real layout rather than the intrinsic protocol, cells - /// whose content is a `flex flex-col`, or that use `h-full` / `basis-*` (all - /// of which carry a `LayoutBuilder`), stretch correctly instead of asserting - /// `LayoutBuilder does not support returning intrinsic dimensions` (#139). + /// whose content is a `flex flex-col`, or that use `h-full` / `basis-*`, + /// stretch correctly instead of asserting `LayoutBuilder does not support + /// returning intrinsic dimensions` (#139). None of those three carries a + /// `LayoutBuilder` any more, so they would survive the intrinsic protocol + /// too; real layout is still the cheaper path and is what this builds. Widget _buildStretchGrid( int cols, double gapX, @@ -1312,9 +1315,9 @@ class WDiv extends StatelessWidget { } // WindEqualHeightRow measures each cell with a real (loose-height) layout // and re-lays it to at least the row max via a MIN height, instead of the - // intrinsic query IntrinsicHeight would run. A `flex flex-col` cell (which - // carries a LayoutBuilder) can then be stretched without the "LayoutBuilder - // does not support returning intrinsic dimensions" assert (#139), and the + // intrinsic query IntrinsicHeight would run. That kept a `flex flex-col` + // cell clear of the "LayoutBuilder does not support returning intrinsic + // dimensions" assert (#139) back when it carried one, and the // min (never tight) height leaves no residual RenderFlex overflow (#141). rows.add(WindEqualHeightRow(spacing: gapX, children: rowChildren)); } @@ -1636,11 +1639,14 @@ class WDiv extends StatelessWidget { // markNeedsLayout (e.g. textScaler change), it causes a debug assertion: // _debugRelayoutBoundaryAlreadyMarkedNeedsLayout() is not true // + // No path below carries one any more: `h-full` is the `WindFullHeightBox` + // render object. `grid` is the last LayoutBuilder in the widget. + // // Strategy: // - w-full: SizedBox(width: infinity), no LayoutBuilder needed // - w-full + max-w-*: ConstrainedBox + SizedBox, no LayoutBuilder needed // - w-1/2, w-1/3 etc: FractionallySizedBox, no LayoutBuilder needed - // - h-full: LayoutBuilder only when vertical axis is unbounded + // - h-full: WindFullHeightBox, a render object, on both axes if (styles.widthFactor != null || styles.heightFactor != null) { final innerChild = widgetToBuild; @@ -1715,34 +1721,22 @@ class WDiv extends StatelessWidget { } } else if (styles.widthFactor == null) { // Height-only fractional sizing (h-full, h-1/2, etc.) - // Vertical axis is often unbounded (ScrollView/Column), so we need - // LayoutBuilder only for h-full in unbounded contexts. + // The vertical axis is often unbounded (ScrollView/Column), which only + // `h-full` has to resolve against; a fraction of an unbounded height is + // meaningless, so `h-1/2` and friends stay a plain FractionallySizedBox. if (isFullHeight) { - // h-full needs LayoutBuilder to handle unbounded vertical axis - widgetToBuild = LayoutBuilder( - builder: (context, constraints) { - if (!constraints.hasBoundedHeight) { - final screenHeight = MediaQuery.of(context).size.height; - Widget result = SizedBox( - height: screenHeight, - child: innerChild, - ); - if (hasMaxHeightConstraint) { - result = ConstrainedBox( - constraints: BoxConstraints( - maxHeight: styles.constraints!.maxHeight, - ), - child: result, - ); - } - return result; - } - // Bounded context: use FractionallySizedBox - return FractionallySizedBox( - heightFactor: 1.0, - child: innerChild, - ); - }, + // h-full resolves at the render layer, no LayoutBuilder. + // + // The question is only answerable during layout ("is the incoming + // height bounded"), and asking it with a LayoutBuilder defers this + // whole subtree into a second layout pass. A consumer measured 1056 + // of them in one eight-scroll session against 258 widget builds. + logger.wrapWith("WindFullHeightBox", "h-full"); + widgetToBuild = WindFullHeightBox( + fallbackHeight: MediaQuery.of(context).size.height, + maxHeight: + hasMaxHeightConstraint ? styles.constraints!.maxHeight : null, + child: innerChild, ); } else { // h-1/2, h-1/3, etc: FractionallySizedBox (no LayoutBuilder) @@ -1762,49 +1756,20 @@ class WDiv extends StatelessWidget { } } else { // Both width and height factors (e.g., w-full h-full, w-1/2 h-1/2) - // Use LayoutBuilder only when needed for unbounded axis - final bool needsLayoutBuilder = isFullHeight; // h-full may be unbounded - if (needsLayoutBuilder) { - widgetToBuild = LayoutBuilder( - builder: (context, constraints) { - final bool heightUnbounded = !constraints.hasBoundedHeight; - final double? effectiveHeight = heightUnbounded - ? MediaQuery.of(context).size.height * - (styles.heightFactor ?? 1.0) - : null; - - Widget result; - if (effectiveHeight != null) { - // Unbounded height: use calculated size - result = SizedBox( - width: isFullWidth ? double.infinity : null, - height: effectiveHeight, - child: innerChild, - ); - } else { - // Bounded context: FractionallySizedBox handles both axes - result = FractionallySizedBox( - widthFactor: styles.widthFactor, - heightFactor: styles.heightFactor, - child: innerChild, - ); - } - - if (hasMaxWidthConstraint || hasMaxHeightConstraint) { - result = ConstrainedBox( - constraints: BoxConstraints( - maxWidth: hasMaxWidthConstraint - ? styles.constraints!.maxWidth - : double.infinity, - maxHeight: hasMaxHeightConstraint - ? styles.constraints!.maxHeight - : double.infinity, - ), - child: result, - ); - } - return result; - }, + // `h-full` carries the width factor into the same render-layer box; + // everything else is a plain FractionallySizedBox on both axes. + if (isFullHeight) { + // Both axes, height full: the same render-layer box as the + // height-only path, carrying the width factor too. + logger.wrapWith("WindFullHeightBox", "w+h-full"); + widgetToBuild = WindFullHeightBox( + fallbackHeight: MediaQuery.of(context).size.height, + widthFactor: styles.widthFactor, + maxWidth: + hasMaxWidthConstraint ? styles.constraints!.maxWidth : null, + maxHeight: + hasMaxHeightConstraint ? styles.constraints!.maxHeight : null, + child: innerChild, ); } else { // Both fractional, neither h-full: FractionallySizedBox handles it diff --git a/lib/src/widgets/wind_equal_height_row.dart b/lib/src/widgets/wind_equal_height_row.dart index cddd3077..aa77e85e 100644 --- a/lib/src/widgets/wind_equal_height_row.dart +++ b/lib/src/widgets/wind_equal_height_row.dart @@ -5,18 +5,20 @@ import 'package:flutter/widgets.dart'; /// REAL two-pass layout rather than the intrinsic-sizing protocol. /// /// This is the intrinsic-free replacement for `IntrinsicHeight` + a -/// `Row(crossAxisAlignment: stretch)` in the grid `items-stretch` path. Wind -/// cell content frequently contains a `LayoutBuilder` (flex cross-axis stretch, -/// `h-full`, `basis-*`), and `LayoutBuilder` cannot answer intrinsic or -/// dry-layout queries, so `IntrinsicHeight` asserts `LayoutBuilder does not -/// support returning intrinsic dimensions` the moment it has to stretch an -/// unequal cell (issue #139). This widget instead lays each child out for real -/// with a loose height to measure it, then lays it out again to the row's max -/// height via a MIN constraint (never a tight one). Real layout is exactly what -/// `LayoutBuilder` supports, so a `flex flex-col` cell stretches without -/// asserting; and because a cell is never forced BELOW its own content height, a -/// stretched cell leaves no residual `RenderFlex overflowed` warning the way a -/// tight re-lay could on fractional (sub-pixel) content (issue #141). +/// `Row(crossAxisAlignment: stretch)` in the grid `items-stretch` path. +/// `IntrinsicHeight` measures through the intrinsic protocol, which a +/// `LayoutBuilder` cannot answer, so it asserts `LayoutBuilder does not support +/// returning intrinsic dimensions` the moment it has to stretch a cell carrying +/// one (issue #139). Flex cross-axis stretch, `h-full` and `basis-*` each did +/// when that was filed and are render objects now, but a nested `grid` still +/// does, and a cell subtree is arbitrary caller content either way. This widget +/// instead lays each child out for real with a loose height to measure it, then +/// lays it out again to the row's max height via a MIN constraint (never a tight +/// one). Real layout is exactly what `LayoutBuilder` supports, so a `flex +/// flex-col` cell stretches without asserting; and because a cell is never +/// forced BELOW its own content height, a stretched cell leaves no residual +/// `RenderFlex overflowed` warning the way a tight re-lay could on fractional +/// (sub-pixel) content (issue #141). /// /// Every child is given an equal share of the incoming width (`(maxWidth - /// spacing * (n - 1)) / n`), matching the grid's fixed column count, so callers diff --git a/lib/src/widgets/wind_full_height.dart b/lib/src/widgets/wind_full_height.dart new file mode 100644 index 00000000..c808fa11 --- /dev/null +++ b/lib/src/widgets/wind_full_height.dart @@ -0,0 +1,231 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Fills the incoming height, falling back to [fallbackHeight] when there is +/// none to fill. +/// +/// This is what `h-full` composes to. The class exists because the question it +/// answers, "is the incoming height bounded", is only answerable during layout, +/// and the widget-layer way to ask it is a [LayoutBuilder]. A [LayoutBuilder] +/// defers its whole subtree into a second layout pass, and `h-full` is common +/// enough on a scrolling screen for that to show: a consumer measured 1056 of +/// them in one eight-scroll session against 258 widget builds, one per element +/// carrying the class, re-run on every frame. +/// +/// A render object reads `constraints` directly, so it needs no deferral and no +/// second pass. It is the same move [WindFractionBasis] and [WindMinWidthBox] +/// already make for their own sizing questions. +/// +/// [fallbackHeight] is passed in rather than read here because a render object +/// has no [BuildContext] and so cannot reach [MediaQuery]. The caller reads it +/// during build, which is where that lookup belongs anyway: it registers the +/// dependency, so a rotation or a window resize rebuilds and updates it. +class WindFullHeightBox extends SingleChildRenderObjectWidget { + /// The height to take when the incoming constraints do not bound one. + /// + /// The screen height, in every current caller. A `Column` child and a sliver + /// both offer an unbounded height, and "as tall as it wants" is not a size, + /// so `h-full` has to name a number instead. + final double fallbackHeight; + + /// `1.0` for `w-full`, null to leave the width to the incoming constraints. + /// + /// Any fraction, not just 1.0: `w-1/2 h-full` reaches this class with 0.5. + /// An earlier version of this doc claimed 1.0 was the only value, which the + /// suite contradicts. + final double? widthFactor; + + /// `max-w-*`, or null when the class is absent. + final double? maxWidth; + + /// `max-h-*`, or null when the class is absent. + final double? maxHeight; + + /// Creates a [WindFullHeightBox]. + const WindFullHeightBox({ + super.key, + required this.fallbackHeight, + this.widthFactor, + this.maxWidth, + this.maxHeight, + required super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderFullHeight( + fallbackHeight, + widthFactor, + maxWidth, + maxHeight, + ); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderObject renderObject, + ) { + (renderObject as _RenderFullHeight) + ..fallbackHeight = fallbackHeight + ..widthFactor = widthFactor + ..maxWidth = maxWidth + ..maxHeight = maxHeight; + } +} + +class _RenderFullHeight extends RenderProxyBox { + _RenderFullHeight( + this._fallbackHeight, + this._widthFactor, + this._maxWidth, + this._maxHeight, + ); + + double _fallbackHeight; + set fallbackHeight(double value) { + if (value == _fallbackHeight) return; + _fallbackHeight = value; + markNeedsLayout(); + } + + double? _widthFactor; + set widthFactor(double? value) { + if (value == _widthFactor) return; + _widthFactor = value; + markNeedsLayout(); + } + + double? _maxWidth; + set maxWidth(double? value) { + if (value == _maxWidth) return; + _maxWidth = value; + markNeedsLayout(); + } + + double? _maxHeight; + set maxHeight(double? value) { + if (value == _maxHeight) return; + _maxHeight = value; + markNeedsLayout(); + } + + /// The height this box resolves to under [constraints]. + /// + /// The bounded and unbounded cases differ only in where the number comes + /// from, and `max-h-*` clamps both. That is narrower than it sounds, and the + /// closing `constrainHeight` is why. + /// + /// Under a LOOSE bounded height the cap now applies where it used to be + /// discarded: the widget-layer version wrapped no `ConstrainedBox` on its + /// bounded branch at all, so `h-full max-h-[120px]` under a + /// `ConstrainedBox(maxHeight: 400)` rendered 400 and now renders 120. + /// + /// Under a TIGHT one it still yields the parent's height, and that is correct + /// rather than the same bug: a tight constraint is the parent stating an + /// exact size, and no className overrides it. `constrainHeight` is what keeps + /// this box honest about that. + double _heightFor(BoxConstraints constraints) { + final double available = + constraints.hasBoundedHeight ? constraints.maxHeight : _fallbackHeight; + final double capped = + _maxHeight == null ? available : math.min(available, _maxHeight!); + + return constraints.constrainHeight(capped); + } + + /// The width constraints to hand the child. + /// + /// Untouched unless a `w-*` fraction asked for a share, which is what the + /// widget-layer `SizedBox(width: double.infinity)` and + /// `FractionallySizedBox(widthFactor: ...)` both did in their own branches. + /// + /// The cap applies AFTER the fraction, and that is a fix rather than a port. + /// The widget-layer version put its `ConstrainedBox` outside the + /// `FractionallySizedBox`, but `BoxConstraints.enforce` clamps an additional + /// constraint into the incoming range, so against the tight width the + /// fraction had already produced the cap was discarded: `w-1/2 h-full + /// max-w-[100px]` in a 300 pixel parent rendered 150 and now renders 100. + (double, double) _widthRangeFor(BoxConstraints constraints) { + double minWidth = constraints.minWidth; + double maxWidth = constraints.maxWidth; + + if (_widthFactor != null && constraints.hasBoundedWidth) { + final double target = constraints.maxWidth * _widthFactor!; + minWidth = target; + maxWidth = target; + } + + if (_maxWidth != null && maxWidth > _maxWidth!) { + maxWidth = _maxWidth!; + if (minWidth > maxWidth) minWidth = maxWidth; + } + + return (minWidth, maxWidth); + } + + @override + void performLayout() { + final double height = _heightFor(constraints); + final (double minWidth, double maxWidth) = _widthRangeFor(constraints); + + final RenderBox? target = child; + // Reachable: a childless `WDiv` (a rule, a divider, a spacer) carrying only + // `h-full` builds no core structure, so this box gets a null child. + if (target == null) { + size = constraints.constrain(Size(minWidth, height)); + return; + } + + target.layout( + BoxConstraints( + minWidth: minWidth, + maxWidth: maxWidth, + minHeight: height, + maxHeight: height, + ), + parentUsesSize: true, + ); + + // The width comes from the child so an unconstrained axis still hugs, the + // height from the resolution above so a bounded parent still gets the full + // fill it asked for. + size = constraints.constrain(Size(target.size.width, height)); + } + + // Intrinsics are deliberately left to `RenderProxyBox`, which forwards them + // to the child. + // + // That is the honest answer for a box whose job is to take what it is given: + // its natural height is its content's, and the fill happens against whatever + // the parent then offers. The first version overrode both to report + // [fallbackHeight], reasoning that a fill box "wants" the screen. Under an + // `IntrinsicHeight` beside a 60 pixel sibling that made the row 600 rather + // than 60, which is the opposite of what `h-full` means. + // + // Answering at all is the change. The old `LayoutBuilder` could not, so an + // `IntrinsicHeight` anywhere above `h-full` threw, and that limitation was + // documented rather than fixed. + + @override + Size computeDryLayout(BoxConstraints constraints) { + final double height = _heightFor(constraints); + final (double minWidth, double maxWidth) = _widthRangeFor(constraints); + + final RenderBox? target = child; + if (target == null) { + return constraints.constrain(Size(minWidth, height)); + } + + final Size childSize = target.getDryLayout( + BoxConstraints( + minWidth: minWidth, + maxWidth: maxWidth, + minHeight: height, + maxHeight: height, + ), + ); + + return constraints.constrain(Size(childSize.width, height)); + } +} diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 6ed5eae0..fbe0496e 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,7 +2,7 @@ name: wind-ui description: "fluttersdk_wind 1.5: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.13.2 +version: 2.14.0 --- @@ -231,7 +231,7 @@ Wind hides most boilerplate but never changes Flutter's "constraints down, sizes `items-stretch` inside a `SingleChildScrollView` needs an `IntrinsicHeight` wrapper from native Flutter; Wind has no token for it. Rare; reach for it when row children inside a scroll must match heights. -**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content triggers Wind's internal `LayoutBuilder` paths: `h-full` (only in an unbounded-height context) or a flex `basis-*` (a single `LayoutBuilder` around the surrounding flex). `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). Escape hatches: use explicit `h-*` / `size-*` instead of `h-full`; do not wrap such content in `IntrinsicHeight`; for equal-height rows use a `Stack` + `Positioned(top:0,bottom:0)`. Wind's own `items-stretch` column equalizes cross-axis size without you adding `IntrinsicHeight` (it uses its own `LayoutBuilder` internally, so reach for it INSTEAD of `IntrinsicHeight`, not nested inside one). +**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content contains a `grid`, which composes a `Wrap` inside a `LayoutBuilder` to compute its column width. `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). `h-full` and flex `basis-*` are NO LONGER triggers: both resolve through render objects (`WindFullHeightBox`, `WindMainExtentProvider`), and a render object answers intrinsics. Escape hatches for the `grid` case: explicit `h-*` / `size-*` cells, or a `Stack` + `Positioned(top:0,bottom:0)`; Wind's own `items-stretch` grid equalizes row heights with real layout, so reach for it INSTEAD of `IntrinsicHeight`. ## 7. className formatting diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md index 1e2ae4e6..de8c4f3b 100644 --- a/skills/wind-ui/references/layouts.md +++ b/skills/wind-ui/references/layouts.md @@ -387,7 +387,7 @@ WDiv( > **Column width-stretch is intrinsic-free (no `IntrinsicHeight` needed).** The reverse case, a `flex flex-col items-stretch` (equal child WIDTHS), needs no `IntrinsicHeight`: Wind's column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves without a `LayoutBuilder`. So a `flex flex-col` (with or without `basis-*`) renders under an `IntrinsicHeight`, a `Table`, or an `items-stretch` grid cell without asserting. Only the ROW height-match above still uses `IntrinsicHeight`. -> **Caveat: `h-full` still fails through an `IntrinsicHeight`.** A child that resolves `h-full` in an unbounded-height context still uses a Wind internal `LayoutBuilder`, so under an `IntrinsicHeight` it throws `LayoutBuilder does not support returning intrinsic dimensions` (a Flutter constraint, not a Wind bug). Keep children explicitly sized under an `IntrinsicHeight` and avoid `h-full` there. (Fractional `basis-*` no longer triggers this: it resolves against the flex's own extent via a real render object.) For equal-height cards outside a scroll, prefer fixed `h-*` cells or a `Stack` + `Positioned(top:0,bottom:0)`. +> **`h-full` is intrinsic-safe now.** It used to resolve through a Wind-internal `LayoutBuilder` and throw `LayoutBuilder does not support returning intrinsic dimensions` under an `IntrinsicHeight`; it is the `WindFullHeightBox` render object now, so it renders there, in a `Table` cell, and in an `items-stretch` grid cell without asserting, and it reports its child's intrinsic height so it matches the tallest sibling rather than the screen. The one remaining `LayoutBuilder` path is `grid`. For equal-height cards outside a scroll, `h-full` under an `IntrinsicHeight` now works; a `Stack` + `Positioned(top:0,bottom:0)` remains the cheaper shape. --- diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md index f3e12cdb..3602f3b4 100644 --- a/skills/wind-ui/references/tailwind-divergence.md +++ b/skills/wind-ui/references/tailwind-divergence.md @@ -44,7 +44,7 @@ The base rule: Wind aims for syntactic familiarity, not semantic equivalence. Mo | `w-full` inside a flex Row | Works (max-width: 100%) | A bare `w-full` row child is treated as `flex-1` (wrapped in `Expanded`) and fills the row; `flex-1` is the clearer, idiomatic choice. `md:w-full` is not auto-expanded. | | `h-full` inside a vertical scroll | Works (max-height: 100%) | Raises an actionable dev assert ("use `flex-1` inside a `flex flex-col` instead of `h-full` inside a vertical scroll"); the scrollable parent threads a vertical-unbounded flag so the child can fail fast. Stripped in release. | | `overflow-x-auto` + `w-full min-w-[Npx]` (shadcn Table pattern) | Fills container, scrolls when content exceeds it | Same behavior, composed from existing tokens (no new token). `w-full` inside a horizontal scroll is sized to `max(viewport, min-w-*)` via the threaded viewport width instead of asserting on the scroll's unbounded width: fills on wide, scrolls on narrow. | -| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | Wind's own flex is intrinsic-safe: a `flex flex-col` (smart stretch, explicit `items-stretch`, or `basis-*`) uses NO `LayoutBuilder` (column stretch is the `WindCrossStretch` render object; `basis-*` resolves against the flex extent via `WindMainExtentProvider`), so it renders under an `IntrinsicHeight`/`Table`/grid cell without asserting. Still avoid wrapping ARBITRARY subtrees in `IntrinsicHeight`/`IntrinsicWidth` inside sheet/route open animations (they can raise `RenderBox was not laid out`); for a connector or rail prefer a `Stack` + `Positioned(top: 0, bottom: 0)` line or wind's `items-stretch` column. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. | +| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | Wind's own flex is intrinsic-safe, and so is `h-full` (the `WindFullHeightBox` render object): a `flex flex-col` (smart stretch, explicit `items-stretch`, or `basis-*`) uses NO `LayoutBuilder` (column stretch is the `WindCrossStretch` render object; `basis-*` resolves against the flex extent via `WindMainExtentProvider`), so it renders under an `IntrinsicHeight`/`Table`/grid cell without asserting. Still avoid wrapping ARBITRARY subtrees in `IntrinsicHeight`/`IntrinsicWidth` inside sheet/route open animations (they can raise `RenderBox was not laid out`); for a connector or rail prefer a `Stack` + `Positioned(top: 0, bottom: 0)` line or wind's `items-stretch` column. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. | | `overflow-y-auto` | Native browser scrollbar | Renders Flutter scroll view; needs constructor `scrollPrimary: true` for iOS tap-to-top | | `bg-red-500/50` | Color with 50% opacity (v3.x+) | Same syntax, same semantics | | `dark:bg-gray-900` | Active only when dark mode is enabled | Active only when `WindThemeData.brightness == Brightness.dark`; required to pair every color | diff --git a/skills/wind-ui/references/tokens.md b/skills/wind-ui/references/tokens.md index b56de159..ec21832c 100644 --- a/skills/wind-ui/references/tokens.md +++ b/skills/wind-ui/references/tokens.md @@ -79,7 +79,7 @@ Inline color escape hatches that bypass the cache key: | `align-content-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` / `-stretch` | Wrap-only, `WrapAlignment` for runs | | `align-self-start` / `-end` / `-center` / `-stretch` / `-auto` (or the `self-*` shorthand) | Per-child cross-axis override | | `axis-min` / `axis-max` | Wind-only: `MainAxisSize.min` / `.max` on the parent flex | -| `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths. Add `items-stretch` for equal-height rows: each row is a real-layout equal-height row (measures each cell, re-lays to at least the tallest via a MIN height, never a tight squeeze), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` (LayoutBuilder-bearing) stretch without asserting or overflowing (#139, #141) | +| `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths. Add `items-stretch` for equal-height rows: each row is a real-layout equal-height row (measures each cell, re-lays to at least the tallest via a MIN height, never a tight squeeze), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` stretch without asserting or overflowing (#139, #141); none of those three carries a `LayoutBuilder` any more | | `order-0` through `order-12` | Child order index | | `order-first` / `order-last` / `order-none` | Sentinel order (first=-9999, last=9999, none=0) | | `order-[N]` | Arbitrary signed integer (e.g. `order-[-5]`) | diff --git a/test/flex/intrinsic_safe_layout_test.dart b/test/flex/intrinsic_safe_layout_test.dart index 3ce12332..898471c4 100644 --- a/test/flex/intrinsic_safe_layout_test.dart +++ b/test/flex/intrinsic_safe_layout_test.dart @@ -363,4 +363,76 @@ void main() { expect(error.toString(), contains('flex-1')); }); }); + + group('(e) h-full is intrinsic-safe', () { + // It was not, and the limitation was documented rather than fixed: `h-full` + // resolved through a `LayoutBuilder`, which cannot answer an intrinsic + // query, so any `IntrinsicHeight` above it threw. `SKILL.md`, three + // reference pages and `doc/layout/sizing.md` all carried the caveat and the + // escape hatch ("use explicit h-* instead"). + // + // `h-full` is a render object now (`WindFullHeightBox`), and a render + // object answers intrinsics. Verified as an A/B against master, which + // throws `LayoutBuilder does not support returning intrinsic dimensions` on + // exactly this tree. + testWidgets('h-full under an IntrinsicHeight no longer throws', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + SizedBox(width: 80, height: 60), + WDiv(className: 'h-full w-[40px] bg-red-500'), + ], + ), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('and it matches the tallest sibling rather than collapsing', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + SizedBox(width: 80, height: 60), + WDiv( + className: 'h-full w-[40px]', + child: SizedBox.expand(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + + // The 60 pixel sibling sets the row height; `h-full` fills it. Asserting + // the number rather than just the absence of a throw, because a box that + // silently collapsed to zero would also not throw. + expect(tester.getSize(find.byType(SizedBox).last).height, 60); + }); + }); } diff --git a/test/widgets/w_div/full_height_sizing_test.dart b/test/widgets/w_div/full_height_sizing_test.dart new file mode 100644 index 00000000..974ebd74 --- /dev/null +++ b/test/widgets/w_div/full_height_sizing_test.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Characterisation tests for `h-full`, pinned before its implementation moves +/// off `LayoutBuilder`. +/// +/// `h-full` has to answer a question only layout can answer: is the incoming +/// height bounded. When it is, the element fills it; when it is not (a `Column` +/// child, a sliver), there is nothing to fill and the element falls back to the +/// screen height. That branch is why the path is wrapped in a `LayoutBuilder`, +/// and a `LayoutBuilder` defers its whole subtree into a second layout pass: +/// measured in a consumer at 1056 of them in one eight-scroll session against +/// 258 widget builds. +/// +/// Every case below passes against the `LayoutBuilder` implementation. They are +/// written first precisely so that the replacement can be judged by whether +/// they all still pass, rather than by reading two layout algorithms side by +/// side and hoping. +/// +/// The combinations that matter are boundedness, the presence of a width +/// factor, and `max-h-*`, because those are the three things the old code +/// branched on. +/// +/// Everything is measured on a keyed CHILD rather than on the `WDiv` itself. +/// `FractionallySizedBox` is an overflow box: it takes the space it is offered +/// and applies the factor to its child, so `getSize(find.byType(WDiv))` reports +/// the offered space in every case and cannot tell `h-1/2` from `h-full`. The +/// first version of this file measured there and read four passing behaviours +/// as failures. +void main() { + setUp(WindParser.clearCache); + + /// Pumps [child] inside a box of a known size, so `h-full` sees a BOUNDED + /// height of exactly 400. + Future pumpBounded(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Center( + child: SizedBox(width: 300, height: 400, child: child), + ), + ), + ), + ); + } + + /// Pumps [child] where the vertical axis is UNBOUNDED, which is what a + /// `Column` hands its children. + Future pumpUnbounded(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [SizedBox(width: 300, child: child)], + ), + ), + ), + ); + } + + group('h-full in a bounded height', () { + testWidgets('fills the incoming height', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)).height, 400); + }); + + testWidgets('with w-full fills both axes', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'w-full h-full', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)), const Size(300, 400)); + }); + + testWidgets('a TIGHT parent wins over max-h-*, which is correct', ( + tester, + ) async { + // `pumpBounded` uses a `SizedBox(height: 400)`, a tight constraint: the + // parent is stating an exact size, and no className overrides that. The + // first version of this file asserted 120 here and called the 400 a + // defect; it is Flutter's constraint model working. + await pumpBounded( + tester, + const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), + ); + + expect(tester.getSize(find.byType(_Probe)).height, 400); + }); + + testWidgets('a LOOSE parent honours max-h-*, which master did not', ( + tester, + ) async { + // The behaviour that actually changed. Master wrapped no `ConstrainedBox` + // on its bounded branch, so the cap was discarded outright: this rendered + // 400 there and renders 120 here. Verified as an A/B against master. + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 300, maxHeight: 400), + child: const WDiv( + className: 'h-full max-h-[120px]', + child: _Probe(), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(_Probe)).height, 120); + }); + + testWidgets('max-w-* applies after the width fraction', (tester) async { + // Also a fix. Master put its `ConstrainedBox` outside the + // `FractionallySizedBox`, and `BoxConstraints.enforce` clamps an + // additional constraint into the incoming range, so against the tight + // width the fraction had already produced the cap was discarded: + // `clamp(100, 150, 150)` is 150. Master renders 150, this renders 100. + await pumpBounded( + tester, + const WDiv(className: 'w-1/2 h-full max-w-[100px]', child: _Probe()), + ); + + expect(tester.getSize(find.byType(_Probe)).width, 100); + }); + + testWidgets('a child is laid out against the filled height', ( + tester, + ) async { + await pumpBounded( + tester, + const WDiv( + className: 'h-full', + child: WDiv(className: 'h-full bg-red-500'), + ), + ); + + // Both boxes fill: the inner one sees the outer's 400 as its own bound. + for (final Size size in tester.widgetList(find.byType(WDiv)).map( + (WDiv w) => tester.getSize(find.byWidget(w)), + )) { + expect(size.height, 400); + } + }); + }); + + group('h-full in an unbounded height', () { + testWidgets('falls back to the screen height rather than asserting', ( + tester, + ) async { + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + final double screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.byType(_Probe)).height, screen); + }); + + testWidgets('max-h-* clamps the screen-height fallback', (tester) async { + await pumpUnbounded( + tester, + const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), + ); + + expect(tester.getSize(find.byType(_Probe)).height, 120); + }); + + testWidgets('with w-full still fills the bounded width', (tester) async { + await pumpUnbounded( + tester, const WDiv(className: 'w-full h-full', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)).width, 300); + }); + }); + + group('the outer box, which the child measurements do not see', () { + // Pinned because the replacement must not change what the element itself + // reports to ITS parent, only how it gets there. `FractionallySizedBox` is + // an overflow box and takes the space it is offered; the unbounded branch + // is a `SizedBox` and hugs the height it chose. + testWidgets('takes the offered space when the height is bounded', ( + tester, + ) async { + await pumpBounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + expect(tester.getSize(find.byType(WDiv)), const Size(300, 400)); + }); + + testWidgets('hugs the fallback height when there is none to fill', ( + tester, + ) async { + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + final double screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.byType(WDiv)).height, screen); + }); + }); + + group('h-fraction is unaffected, in either context', () { + testWidgets('h-1/2 takes half a bounded height', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'h-1/2', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)).height, 200); + }); + + testWidgets('w-1/2 h-1/2 takes half of both', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'w-1/2 h-1/2', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)), const Size(150, 200)); + }); + }); + + group('h-full updates in place', () { + // `updateRenderObject` and the four setters. A `WDiv` that keeps its + // identity while its className changes must re-lay-out rather than keep the + // size it resolved the first time, and the same applies when the screen + // itself changes: `fallbackHeight` is read during build, so a rotation is + // an update rather than a rebuild from scratch. + testWidgets('a changed max-h-* re-resolves the height', (tester) async { + await pumpUnbounded( + tester, + const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).height, 120); + + await pumpUnbounded( + tester, + const WDiv(className: 'h-full max-h-[200px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).height, 200); + }); + + testWidgets('a changed width factor re-resolves the width', (tester) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 300); + + await pumpBounded( + tester, + const WDiv(className: 'w-1/2 h-full', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 150); + }); + + testWidgets('a changed max-w-* re-resolves the width', (tester) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full max-w-[200px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 200); + + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full max-w-[100px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 100); + }); + + testWidgets('a changed screen height re-resolves the fallback', ( + tester, + ) async { + addTearDown(tester.view.reset); + + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + expect(tester.getSize(find.byType(_Probe)).height, 800); + + tester.view.physicalSize = const Size(400, 500); + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + expect(tester.getSize(find.byType(_Probe)).height, 500); + }); + }); + + group('h-full with no child', () { + // A childless `WDiv` is a real shape (a divider, a rule, a spacer), and the + // box still has to report a size rather than reaching for a child that is + // not there. + testWidgets('still resolves a height with nothing to lay out', ( + tester, + ) async { + // No `w-[40px]`: any explicit width makes `WDiv` build a core structure, + // so the box gets a child and this stops testing the null branch. The + // first version carried one and covered nothing. + await pumpUnbounded(tester, const WDiv(className: 'h-full')); + + final double screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.byType(WDiv)).height, screen); + }); + }); + + group('h-full answers a dry layout', () { + // Asked directly, because the widget trees that route through + // `computeDryLayout` do so from inside their own layout and are awkward to + // build on purpose. The contract is what matters: a dry layout has to agree + // with the real one, or a parent that measures before laying out gets a + // different answer than the one it then renders. + testWidgets('and agrees with the size it then lays out', (tester) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full', child: _Probe()), + ); + + final RenderBox box = tester.renderObject( + find.byType(WDiv), + ); + const BoxConstraints incoming = BoxConstraints( + maxWidth: 300, + maxHeight: 400, + ); + + expect(box.getDryLayout(incoming), box.size); + }); + + testWidgets('with no child, and with an unbounded height', (tester) async { + await pumpUnbounded(tester, const WDiv(className: 'h-full')); + + final RenderBox box = tester.renderObject( + find.byType(WDiv), + ); + + expect( + box.getDryLayout(const BoxConstraints(maxWidth: 300)).height, + box.size.height, + ); + }); + }); +} + +/// A child that fills whatever it is given, so its measured size IS the box the +/// sizing classes produced. +class _Probe extends StatelessWidget { + const _Probe(); + + @override + Widget build(BuildContext context) => const SizedBox.expand(); +} diff --git a/test/widgets/w_div/sizing_test.dart b/test/widgets/w_div/sizing_test.dart index d77a9f64..19b53cd8 100644 --- a/test/widgets/w_div/sizing_test.dart +++ b/test/widgets/w_div/sizing_test.dart @@ -101,12 +101,18 @@ void main() { ), ); - expect(find.byType(FractionallySizedBox), findsOneWidget); - final FractionallySizedBox box = tester.widget( - find.byType(FractionallySizedBox), - ); - expect(box.widthFactor, 0.5); - expect(box.heightFactor, 1.0); + // Asserted on the resulting SIZE rather than on the widget that + // produced it. The previous version looked for a `FractionallySizedBox` + // carrying the two factors, which pinned one composition rather than the + // behaviour: `h-full` now resolves through `WindFullHeightBox` and the + // element sizes identically. A white-box assertion here fails on a change + // that a user cannot see, and passes on one they can. + final Size size = tester.getSize(find.text('Test')); + final Size screen = + tester.view.physicalSize / tester.view.devicePixelRatio; + + expect(size.width, screen.width / 2); + expect(size.height, screen.height); }); group('Sizing Optimization Tests', () { @@ -200,12 +206,24 @@ void main() { ), ); - final wDivFinder = find.byType(WDiv); + // The height it RESOLVED TO, not the widget it used to get there. + // This asserted a `LayoutBuilder` descendant, which is the thing the + // render-layer rewrite removed on purpose: `h-full` in an unbounded + // column still falls back to the screen height, and that fallback is + // the behaviour worth pinning. + final double screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.text('Test')).height, screenHeight); + + // Deliberately no assertion about WHICH widget produced that height. + // The old one named `LayoutBuilder`, and naming its replacement would + // repeat the mistake: the next rewrite would fail this test without + // changing anything a user can observe. expect( - find.descendant(of: wDivFinder, matching: find.byType(LayoutBuilder)), + find.byType(WDiv), findsOneWidget, reason: - 'h-full in unbounded parent requires LayoutBuilder to check constraints', + 'h-full in an unbounded parent falls back to the screen height', ); }); });