From 452de397d3a3fcd2ab93cb1ac7fba62f0134d42b Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 15:29:58 +0300 Subject: [PATCH 1/8] feat(layout): intrinsic-safe flex stretch and min-width-stretch scroll (WIND-4) --- .../state/wind_min_width_scroll_scope.dart | 45 +++ lib/src/widgets/w_div.dart | 278 ++++++++----- lib/src/widgets/wind_equal_height_row.dart | 261 ++++++++++++ lib/src/widgets/wind_min_width_scroll.dart | 183 +++++++++ test/flex/intrinsic_safe_layout_test.dart | 371 ++++++++++++++++++ 5 files changed, 1030 insertions(+), 108 deletions(-) create mode 100644 lib/src/state/wind_min_width_scroll_scope.dart create mode 100644 lib/src/widgets/wind_min_width_scroll.dart create mode 100644 test/flex/intrinsic_safe_layout_test.dart diff --git a/lib/src/state/wind_min_width_scroll_scope.dart b/lib/src/state/wind_min_width_scroll_scope.dart new file mode 100644 index 00000000..bff92ac2 --- /dev/null +++ b/lib/src/state/wind_min_width_scroll_scope.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; + +import '../widgets/wind_min_width_scroll.dart'; + +/// Threads a scrollable parent's axis information down to its content, since a +/// scrollable `WDiv` cannot inspect a descendant's `className` directly. +/// +/// A `WDiv` with a scrollable axis installs this scope for its scroll content: +/// +/// - `overflow-x-auto` / `overflow-x-scroll` sets [horizontalPort] so a `w-full` +/// descendant sizes to `max(viewport, min-w-*)` (fill on desktop, scroll on +/// narrow) instead of asserting on the scroll's unbounded width. +/// - `overflow-y-auto` / `overflow-y-scroll` sets [verticalUnbounded] so a +/// descendant that resolves `h-full` (which would collapse to +/// `double.infinity` on the scroll's unbounded height) can raise an actionable +/// assert pointing at `flex-1` instead. +class WindMinWidthScrollScope extends InheritedWidget { + /// Shared carrier for the horizontal viewport width; non-null only inside a + /// horizontally scrollable parent. + final WindViewportWidthPort? horizontalPort; + + /// Whether the nearest scrollable parent leaves the vertical axis unbounded + /// (a vertical scroll), so `h-full` on a child is a layout error. + final bool verticalUnbounded; + + const WindMinWidthScrollScope({ + super.key, + this.horizontalPort, + this.verticalUnbounded = false, + required super.child, + }); + + /// Returns the nearest [WindMinWidthScrollScope] and subscribes so dependents + /// rebuild when the axis information changes. + static WindMinWidthScrollScope? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(WindMinWidthScrollScope oldWidget) { + return oldWidget.horizontalPort != horizontalPort || + oldWidget.verticalUnbounded != verticalUnbounded; + } +} diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index ee9d1b9a..81eadee2 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -6,10 +6,12 @@ import '../utils/wind_logger.dart'; import 'wind_animation_wrapper.dart'; import '../state/wind_anchor_state_provider.dart'; import '../state/wind_flex_overflow_scope.dart'; +import '../state/wind_min_width_scroll_scope.dart'; import 'w_anchor.dart'; import 'w_button.dart'; import 'w_text.dart'; import 'wind_equal_height_row.dart'; +import 'wind_min_width_scroll.dart'; /// **The Fundamental Building Block of Wind** /// @@ -215,6 +217,7 @@ class WDiv extends StatelessWidget { styles: styles, content: coreContent, logger: logger, + context: context, skipFlexWrap: skipFlexWrap, ); @@ -545,28 +548,39 @@ class WDiv extends StatelessWidget { // when a child actually carries `basis-*`, so the common case is unwrapped. final bool hasBasisChild = _anyChildHasBasis(gappedChildren); if (hasBasisChild) { - return LayoutBuilder( - builder: (context, constraints) { - final double mainExtent = - isColumn ? constraints.maxHeight : constraints.maxWidth; - final resolvedChildren = _applyMainAxisBasis( - gappedChildren, - context, - isColumn: isColumn, - mainExtent: mainExtent, - ); - return _composeFlex( - styles: styles, - isColumn: isColumn, - basisChildren: resolvedChildren, - effectiveMainAxisSize: effectiveMainAxisSize, - isMainAxisScrollable: isMainAxisScrollable, - hasOverflowClip: hasOverflowClip, - needsSpaceDistribution: needsSpaceDistribution, - context: context, - ); - }, + // Resolve `basis-*` intrinsic-safely: fixed `basis-[Npx]` becomes a + // SizedBox at build time; fractional `basis-1/2` becomes a + // WindFractionBasis that reads the flex's own main extent, published by + // the WindMainExtentProvider below. There is no LayoutBuilder, so the flex + // renders under an intrinsic-measuring ancestor (IntrinsicHeight, Table, + // grid cell) without the "LayoutBuilder does not support returning + // intrinsic dimensions" assert. + final port = WindMainExtentPort(); + final resolvedChildren = _applyMainAxisBasis( + gappedChildren, + context, + isColumn: isColumn, + port: port, ); + final Widget flex = _composeFlex( + styles: styles, + isColumn: isColumn, + basisChildren: resolvedChildren, + effectiveMainAxisSize: effectiveMainAxisSize, + isMainAxisScrollable: isMainAxisScrollable, + hasOverflowClip: hasOverflowClip, + needsSpaceDistribution: needsSpaceDistribution, + context: context, + ); + // Only publish the main extent when a fractional basis actually needs it. + if (port.needsProvider) { + return WindMainExtentProvider( + port: port, + isColumn: isColumn, + child: flex, + ); + } + return flex; } return _composeFlex( @@ -596,62 +610,52 @@ class WDiv extends StatelessWidget { required BuildContext context, }) { if (isColumn) { - // Smart cross-axis stretch (column-only): with no explicit `items-*` - // token, each Wind child that does not control its own width is wrapped - // in `SizedBox(width: infinity)` so it fills the column width (CSS - // `align-items: stretch` default). `crossAxisAlignment` stays `start`, - // so an explicit `items-*` disables this path entirely. - Widget buildColumn(bool stretch) { - final List columnChildren; - if (stretch) { - columnChildren = basisChildren.map((child) { - // Gaps and pre-wrapped flex widgets are never stretched. - if (child is SizedBox || child is Flexible || child is Expanded) { - return child; - } - if (!_shouldStretchColumnChild(child)) return child; - return SizedBox(width: double.infinity, child: child); - }).toList(); - } else { - columnChildren = basisChildren; - } - - return WindFlexOverflowScope( - skipExpanded: isMainAxisScrollable, - child: Column( - mainAxisAlignment: - styles.mainAxisAlignment ?? MainAxisAlignment.start, - crossAxisAlignment: - styles.crossAxisAlignment ?? CrossAxisAlignment.start, - mainAxisSize: effectiveMainAxisSize, - textBaseline: styles.textBaseline, - verticalDirection: styles.flexReverse - ? VerticalDirection.up - : VerticalDirection.down, - children: columnChildren, - ), - ); - } + // Cross-axis (width) stretch is active with NO explicit `items-*` token + // (CSS `align-items: stretch` default) OR an explicit `items-stretch`. + // Each stretch-eligible child is wrapped in `WindCrossStretch`, which + // fills the column width when it is bounded and degrades to content width + // when it is not. Unlike the old `LayoutBuilder` + `SizedBox(width: + // infinity)` gate, `WindCrossStretch` is a real render object: it answers + // intrinsic queries (so a stretched column renders under `IntrinsicHeight` + // / a grid cell without asserting) and never forces an infinite width on + // an unbounded slot. An explicit `items-start`/`center`/`end` disables it. + final bool stretchActive = styles.crossAxisAlignment == null || + styles.crossAxisAlignment == CrossAxisAlignment.stretch; + + final List columnChildren = stretchActive + ? basisChildren.map((child) { + // Gaps, pre-wrapped flex widgets, and basis-sized children carry + // their own cross size and are never stretched. + if (child is SizedBox || + child is Flexible || + child is Expanded || + child is WindFractionBasis) { + return child; + } + if (!_shouldStretchColumnChild(child)) return child; + return WindCrossStretch(child: child); + }).toList() + : basisChildren; - // The stretch wrap forces a tight infinite width, which THROWS under an - // unbounded-width constraint (a bare Row main-axis slot, UnconstrainedBox, - // horizontal scroll). Gate it on a finite incoming maxWidth via a - // LayoutBuilder, but ONLY when a stretch-eligible child exists so columns - // that cannot stretch pay no LayoutBuilder cost. An unbounded-width - // column degrades to content-sized children (the pre-stretch behavior) - // instead of crashing. - final bool hasStretchTarget = styles.crossAxisAlignment == null && - basisChildren.any((child) => - child is! SizedBox && - child is! Flexible && - child is! Expanded && - _shouldStretchColumnChild(child)); - if (!hasStretchTarget) { - return buildColumn(false); - } - return LayoutBuilder( - builder: (context, constraints) => - buildColumn(constraints.maxWidth.isFinite), + return WindFlexOverflowScope( + skipExpanded: isMainAxisScrollable, + child: Column( + mainAxisAlignment: + styles.mainAxisAlignment ?? MainAxisAlignment.start, + // `WindCrossStretch` performs the stretch, so the Column itself never + // uses `CrossAxisAlignment.stretch` (which requires a bounded width + // and would crash on an unbounded main-axis slot). Only a non-stretch + // explicit alignment is forwarded. + crossAxisAlignment: stretchActive + ? CrossAxisAlignment.start + : styles.crossAxisAlignment!, + mainAxisSize: effectiveMainAxisSize, + textBaseline: styles.textBaseline, + verticalDirection: styles.flexReverse + ? VerticalDirection.up + : VerticalDirection.down, + children: columnChildren, + ), ); } else { // For Row with space distribution OR overflow-hidden, wrap children with Flexible @@ -682,9 +686,11 @@ class WDiv extends StatelessWidget { } if (!needsFlexible) return child; // Don't wrap gaps, already-flex widgets, or basis-sized children - // (FractionallySizedBox/SizedBox carry an explicit main size). + // (WindFractionBasis/FractionallySizedBox/SizedBox carry an explicit + // main size). if (child is SizedBox || child is FractionallySizedBox || + child is WindFractionBasis || child is Flexible || child is Expanded) { return child; @@ -815,34 +821,39 @@ class WDiv extends StatelessWidget { /// /// A `FractionallySizedBox` cannot be used here because flex hands non-flex /// children an UNBOUNDED main-axis constraint, which makes a fractional box - /// throw. Instead the caller measures the flex's own bounded main extent via - /// a `LayoutBuilder` and passes it as [mainExtent]; the fraction resolves to - /// a concrete pixel size. When [mainExtent] is not finite (unbounded flex), - /// fractional basis degrades to the child's intrinsic size (passthrough); - /// fixed `basis-[Npx]` always applies. + /// throw. Fixed `basis-[Npx]` resolves to a `SizedBox` immediately (no extent + /// needed). Fractional `basis-1/2` is wrapped in a [WindFractionBasis] that + /// reads the flex's own main extent from [port] at layout time (published by + /// a [WindMainExtentProvider]); when that extent is not finite (unbounded + /// flex), it degrades to the child's intrinsic size (passthrough). Neither + /// path uses a `LayoutBuilder`, so the flex stays intrinsic-safe. static List _applyMainAxisBasis( List children, BuildContext context, { required bool isColumn, - required double mainExtent, + required WindMainExtentPort port, }) { return children.map((child) { final basis = _resolveChildBasis(child, context); if (basis == null) return child; - double? size; if (basis.size != null) { - size = basis.size; - } else if (basis.factor != null && mainExtent.isFinite) { - size = mainExtent * basis.factor!; + return SizedBox( + width: isColumn ? null : basis.size, + height: isColumn ? basis.size : null, + child: child, + ); } - if (size == null) return child; - - return SizedBox( - width: isColumn ? null : size, - height: isColumn ? size : null, - child: child, - ); + if (basis.factor != null) { + port.needsProvider = true; + return WindFractionBasis( + port: port, + factor: basis.factor!, + isColumn: isColumn, + child: child, + ); + } + return child; }).toList(); } @@ -1259,6 +1270,7 @@ class WDiv extends StatelessWidget { required WindStyle styles, required Widget? content, required WindLogger logger, + required BuildContext context, bool skipFlexWrap = false, }) { Widget? widgetToBuild = content; @@ -1420,19 +1432,31 @@ class WDiv extends StatelessWidget { if (hasOverflowScroll) { if (styles.overflowX == WindOverflow.scroll || styles.overflowX == WindOverflow.auto) { - // Horizontal scroll only + // Horizontal scroll only. Thread the viewport width down through the + // scope so a `w-full` child fills the viewport when it is wide and + // honors `min-w-*` (so the viewport scrolls) when it is narrow, instead + // of asserting on the scroll's unbounded width. logger.wrapWith( "SingleChildScrollView", "horizontal${scrollPrimary ? ', primary' : ''}", ); - widgetToBuild = SingleChildScrollView( - scrollDirection: Axis.horizontal, - primary: scrollPrimary, - child: widgetToBuild, + final port = WindViewportWidthPort(); + widgetToBuild = WindViewportWidthProvider( + port: port, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + primary: scrollPrimary, + child: WindMinWidthScrollScope( + horizontalPort: port, + child: widgetToBuild ?? const SizedBox.shrink(), + ), + ), ); } else if (styles.overflowY == WindOverflow.scroll || styles.overflowY == WindOverflow.auto) { - // Vertical scroll only + // Vertical scroll only. Signal descendants that the vertical axis is + // unbounded so a child that resolves `h-full` can raise an actionable + // assert instead of a cryptic unbounded-height failure. logger.wrapWith( "SingleChildScrollView", "vertical${scrollPrimary ? ', primary' : ''}", @@ -1440,7 +1464,10 @@ class WDiv extends StatelessWidget { widgetToBuild = SingleChildScrollView( scrollDirection: Axis.vertical, primary: scrollPrimary, - child: widgetToBuild, + child: WindMinWidthScrollScope( + verticalUnbounded: true, + child: widgetToBuild ?? const SizedBox.shrink(), + ), ); } else { // Both directions scroll - use nested ScrollViews @@ -1449,13 +1476,21 @@ class WDiv extends StatelessWidget { "SingleChildScrollView", "both (nested)${scrollPrimary ? ', primary on outer' : ''}", ); - widgetToBuild = SingleChildScrollView( - scrollDirection: Axis.vertical, - primary: scrollPrimary, + final port = WindViewportWidthPort(); + widgetToBuild = WindViewportWidthProvider( + port: port, child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - primary: false, // Inner scroll is never primary - child: widgetToBuild, + scrollDirection: Axis.vertical, + primary: scrollPrimary, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + primary: false, // Inner scroll is never primary + child: WindMinWidthScrollScope( + horizontalPort: port, + verticalUnbounded: true, + child: widgetToBuild ?? const SizedBox.shrink(), + ), + ), ), ); } @@ -1514,10 +1549,37 @@ class WDiv extends StatelessWidget { final bool isFullWidth = styles.widthFactor == 1.0; final bool isFullHeight = styles.heightFactor == 1.0; + // A scrollable ancestor threads its axis info here (it cannot inspect this + // child's className directly). + final WindMinWidthScrollScope? scrollScope = + WindMinWidthScrollScope.maybeOf(context); + + // Actionable dev-time guard (stripped in release): `h-full` inside a + // vertical scroll resolves to an unbounded height and yields a cryptic + // Flutter failure. Point the developer at the `flex-1` fix instead. + assert( + !(isFullHeight && (scrollScope?.verticalUnbounded ?? false)), + 'Wind: `h-full` on a child inside a vertical scroll ' + '(overflow-y-auto/overflow-y-scroll) resolves to an unbounded height. ' + 'Use `flex-1` inside a `flex flex-col` (with the scroll on the column) ' + 'instead of `h-full` inside a vertical scroll.', + ); + // Fast path: width-only sizing (no heightFactor) — avoids LayoutBuilder // Covers: w-full, w-full max-w-*, w-1/2, w-1/3, etc. if (styles.heightFactor == null) { - if (isFullWidth) { + if (isFullWidth && scrollScope?.horizontalPort != null) { + // `w-full` inside a horizontal scroll: fill the viewport when wide, + // honor `min-w-*` (so the viewport scrolls) when narrow. A plain + // SizedBox(width: infinity) would assert on the scroll's unbounded + // width; WindMinWidthBox reads the threaded viewport width instead. + logger.wrapWith("WindMinWidthBox", "w-full in horizontal scroll"); + widgetToBuild = WindMinWidthBox( + port: scrollScope!.horizontalPort!, + floorMinWidth: styles.constraints?.minWidth ?? 0, + child: innerChild, + ); + } else if (isFullWidth) { // w-full: expand to fill available width logger.wrapWith("SizedBox", "w-full (no LayoutBuilder)"); widgetToBuild = SizedBox( diff --git a/lib/src/widgets/wind_equal_height_row.dart b/lib/src/widgets/wind_equal_height_row.dart index 275c1219..cddd3077 100644 --- a/lib/src/widgets/wind_equal_height_row.dart +++ b/lib/src/widgets/wind_equal_height_row.dart @@ -171,3 +171,264 @@ class _RenderEqualHeightRow extends RenderBox bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => defaultHitTestChildren(result, position: position); } + +/// Stretches its child to the enclosing column's cross-axis (width) when that +/// width is bounded, and passes the child through unchanged when it is not. +/// +/// This is the intrinsic-safe replacement for the `LayoutBuilder` + +/// `SizedBox(width: double.infinity)` pair that used to gate `flex flex-col` +/// smart cross-axis stretch (and now also drives explicit `items-stretch` +/// columns). Because it is a real [RenderProxyBox] it answers intrinsic and +/// dry-layout queries by delegating to its child, so a stretched column renders +/// under an intrinsic-measuring ancestor (`IntrinsicHeight`, a `Table`, a +/// Flutter grid cell) without the `LayoutBuilder does not support returning +/// intrinsic dimensions` assert. When the incoming width is unbounded (a bare +/// `Row` main-axis slot, `UnconstrainedBox`, a horizontal scroll) it degrades to +/// the child's content width instead of forcing an infinite width. +class WindCrossStretch extends SingleChildRenderObjectWidget { + const WindCrossStretch({super.key, required super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderCrossStretch(); +} + +class _RenderCrossStretch extends RenderProxyBox { + @override + void performLayout() { + final RenderBox? child = this.child; + if (child == null) { + size = constraints.smallest; + return; + } + if (constraints.maxWidth.isFinite) { + child.layout( + BoxConstraints( + minWidth: constraints.maxWidth, + maxWidth: constraints.maxWidth, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + ), + parentUsesSize: true, + ); + } else { + child.layout(constraints, parentUsesSize: true); + } + size = child.size; + } +} + +/// A mutable carrier for a flex's resolved main-axis extent, written by +/// [WindMainExtentProvider] during layout and read by [WindFractionBasis]. The +/// same instance is shared (via the widget tree) between the provider and the +/// fractional-basis children it feeds. +class WindMainExtentPort { + /// The most recent main-axis extent (row width / column height) published by + /// the enclosing [WindMainExtentProvider]; `null` before the first layout. + double? value; + + /// Set by `_applyMainAxisBasis` when a FRACTIONAL basis child was wrapped, so + /// the caller only pays for a [WindMainExtentProvider] when the extent is + /// actually read (fixed `basis-[Npx]` resolves to a plain `SizedBox`). + bool needsProvider = false; + + final Set _dependents = {}; + + /// Registers a consumer render object so it is re-laid out when [value] + /// changes. A basis child's OWN constraints do not change when the flex's + /// extent does (a flex hands non-flex children an unbounded main axis), so it + /// would otherwise skip layout and read a stale extent. + void addDependent(RenderObject dependent) => _dependents.add(dependent); + + /// Unregisters a consumer render object (on detach or a port swap). + void removeDependent(RenderObject dependent) => _dependents.remove(dependent); + + /// Publishes a new extent and marks every registered consumer for layout when + /// the value actually changed. + void publish(double newValue) { + if (newValue == value) return; + value = newValue; + for (final dependent in _dependents) { + dependent.markNeedsLayout(); + } + } +} + +/// Publishes the enclosing flex's own main-axis extent (row width / column +/// height) through [port] during layout, so fractional `basis-*` children can +/// resolve their fraction without a `LayoutBuilder`. +/// +/// It is a real [RenderProxyBox], so it answers intrinsic/dry-layout queries by +/// delegating to the flex, keeping the whole `basis-*` path intrinsic-safe. +class WindMainExtentProvider extends SingleChildRenderObjectWidget { + /// The shared carrier the flex's basis children read at layout time. + final WindMainExtentPort port; + + /// Whether the enclosing flex is a column (main axis is vertical). + final bool isColumn; + + const WindMainExtentProvider({ + super.key, + required this.port, + required this.isColumn, + required super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderMainExtentProvider(port, isColumn); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderObject renderObject, + ) { + (renderObject as _RenderMainExtentProvider) + ..port = port + ..isColumn = isColumn; + } +} + +class _RenderMainExtentProvider extends RenderProxyBox { + _RenderMainExtentProvider(this._port, this._isColumn); + + WindMainExtentPort _port; + set port(WindMainExtentPort value) => _port = value; + + bool _isColumn; + set isColumn(bool value) { + if (value == _isColumn) return; + _isColumn = value; + markNeedsLayout(); + } + + @override + void performLayout() { + // Publish the flex's own bounded main extent BEFORE laying it out, so the + // WindFractionBasis children the flex lays out (top-down) resolve against a + // fresh value. `publish` marks the registered basis children for layout + // when the extent changed (they otherwise skip layout with a stale value, + // since a flex hands them unchanged unbounded main-axis constraints). The + // marking is done inside `invokeLayoutCallback`, the sanctioned hook for + // dirtying the subtree during layout. Intrinsic queries never call + // performLayout, so this stays intrinsic-safe. + invokeLayoutCallback((_) { + _port.publish(_isColumn ? constraints.maxHeight : constraints.maxWidth); + }); + super.performLayout(); + } +} + +/// Sizes its child along the flex main axis to [factor] of the extent published +/// by [port] (a fraction of the flex container), matching CSS `flex-basis` for +/// `basis-1/2`, `basis-full`, etc. +/// +/// When the published extent is `null` or infinite (an unbounded flex) it +/// passes the child through at its natural size, mirroring the documented +/// degradation of fractional basis on an unbounded main axis. Intrinsic and +/// dry-layout queries delegate to the child, so a fractional `basis-*` renders +/// under an intrinsic-measuring ancestor without a `LayoutBuilder`. +class WindFractionBasis extends SingleChildRenderObjectWidget { + /// The shared carrier written by the enclosing [WindMainExtentProvider]. + final WindMainExtentPort port; + + /// The main-axis fraction of the flex container (e.g. `0.5` for `basis-1/2`). + final double factor; + + /// Whether the enclosing flex is a column (basis sizes height, not width). + final bool isColumn; + + const WindFractionBasis({ + super.key, + required this.port, + required this.factor, + required this.isColumn, + required super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderFractionBasis(port, factor, isColumn); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderObject renderObject, + ) { + (renderObject as _RenderFractionBasis) + ..port = port + ..factor = factor + ..isColumn = isColumn; + } +} + +class _RenderFractionBasis extends RenderProxyBox { + _RenderFractionBasis(this._port, this._factor, this._isColumn); + + WindMainExtentPort _port; + set port(WindMainExtentPort value) { + if (identical(value, _port)) return; + if (attached) _port.removeDependent(this); + _port = value; + if (attached) _port.addDependent(this); + markNeedsLayout(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _port.addDependent(this); + } + + @override + void detach() { + _port.removeDependent(this); + super.detach(); + } + + double _factor; + set factor(double value) { + if (value == _factor) return; + _factor = value; + markNeedsLayout(); + } + + bool _isColumn; + set isColumn(bool value) { + if (value == _isColumn) return; + _isColumn = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final RenderBox? child = this.child; + if (child == null) { + size = constraints.smallest; + return; + } + final double? extent = _port.value; + if (extent != null && extent.isFinite) { + final double main = _factor * extent; + child.layout( + _isColumn + ? BoxConstraints( + minWidth: constraints.minWidth, + maxWidth: constraints.maxWidth, + minHeight: main, + maxHeight: main, + ) + : BoxConstraints( + minWidth: main, + maxWidth: main, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + ), + parentUsesSize: true, + ); + } else { + child.layout(constraints, parentUsesSize: true); + } + size = child.size; + } +} diff --git a/lib/src/widgets/wind_min_width_scroll.dart b/lib/src/widgets/wind_min_width_scroll.dart new file mode 100644 index 00000000..39f51fe5 --- /dev/null +++ b/lib/src/widgets/wind_min_width_scroll.dart @@ -0,0 +1,183 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A mutable carrier for a horizontal scroll viewport's own width, written by +/// [WindViewportWidthProvider] during layout and read by [WindMinWidthBox]. The +/// same instance is shared through [WindMinWidthScrollScope] between the +/// provider (outside the scroll) and a `w-full` child (inside the scroll). +class WindViewportWidthPort { + /// The most recent viewport width published by the enclosing + /// [WindViewportWidthProvider]; `null` before the first layout, infinite when + /// the scroll itself sits in an unbounded-width context. + double? value; + + final Set _dependents = {}; + + /// Registers a consumer render object so it is re-laid out when [value] + /// changes. A `w-full` child inside a horizontal scroll always receives the + /// same (unbounded-width) constraints, so it would otherwise skip layout and + /// read a stale viewport width. + void addDependent(RenderObject dependent) => _dependents.add(dependent); + + /// Unregisters a consumer render object (on detach or a port swap). + void removeDependent(RenderObject dependent) => _dependents.remove(dependent); + + /// Publishes a new viewport width and marks every registered consumer for + /// layout when the value actually changed. + void publish(double newValue) { + if (newValue == value) return; + value = newValue; + for (final dependent in _dependents) { + dependent.markNeedsLayout(); + } + } +} + +/// Wraps a horizontal `SingleChildScrollView` and publishes the viewport's own +/// (bounded) width through [port] during layout. +/// +/// This is the render-side of the "fill on desktop, scroll on narrow" primitive +/// (the shadcn Table pattern): a `w-full` child inside the scroll reads the +/// published width via [WindMinWidthBox] and sizes itself to +/// `max(viewport, min-w-*)`, filling the viewport when it is wide and honoring +/// its min width (so the viewport scrolls) when it is narrow. It is a real +/// [RenderProxyBox], so intrinsic/dry-layout queries delegate to the scroll. +class WindViewportWidthProvider extends SingleChildRenderObjectWidget { + /// The shared carrier the scroll's `w-full` child reads at layout time. + final WindViewportWidthPort port; + + const WindViewportWidthProvider({ + super.key, + required this.port, + required super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderViewportWidthProvider(port); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderObject renderObject, + ) { + (renderObject as _RenderViewportWidthProvider).port = port; + } +} + +class _RenderViewportWidthProvider extends RenderProxyBox { + _RenderViewportWidthProvider(this._port); + + WindViewportWidthPort _port; + set port(WindViewportWidthPort value) => _port = value; + + @override + void performLayout() { + // Publish the viewport's own width BEFORE laying out the scroll (top-down), + // so a WindMinWidthBox deeper in the scroll content reads a fresh value. + // `publish` marks the registered box for layout when the width changed (it + // otherwise skips layout with a stale width, since a horizontal scroll hands + // its content unchanged unbounded-width constraints). The marking runs + // inside `invokeLayoutCallback`, the sanctioned hook for dirtying the + // subtree during layout; intrinsic queries never reach here. + invokeLayoutCallback((_) { + _port.publish(constraints.maxWidth); + }); + super.performLayout(); + } +} + +/// Sizes its child to `max(viewportWidth, floorMinWidth)` along the horizontal +/// axis (a tight width, so a flex row with `Expanded` children still lays out). +/// +/// This replaces the `SizedBox(width: double.infinity)` a bare `w-full` would +/// otherwise produce, which asserts inside a horizontal scroll's unbounded +/// width. On a wide viewport the child fills the viewport (no scrolling); on a +/// narrow viewport the child takes [floorMinWidth] (wider than the viewport, +/// so the enclosing scroll scrolls). Intrinsic/dry-layout queries delegate to +/// the child. +class WindMinWidthBox extends SingleChildRenderObjectWidget { + /// The shared carrier written by the enclosing [WindViewportWidthProvider]. + final WindViewportWidthPort port; + + /// The lower bound on the child's width (from `min-w-[Npx]`); `0` for none. + final double floorMinWidth; + + const WindMinWidthBox({ + super.key, + required this.port, + required this.floorMinWidth, + required super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderMinWidthBox(port, floorMinWidth); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderObject renderObject, + ) { + (renderObject as _RenderMinWidthBox) + ..port = port + ..floorMinWidth = floorMinWidth; + } +} + +class _RenderMinWidthBox extends RenderProxyBox { + _RenderMinWidthBox(this._port, this._floorMinWidth); + + WindViewportWidthPort _port; + set port(WindViewportWidthPort value) { + if (identical(value, _port)) return; + if (attached) _port.removeDependent(this); + _port = value; + if (attached) _port.addDependent(this); + markNeedsLayout(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _port.addDependent(this); + } + + @override + void detach() { + _port.removeDependent(this); + super.detach(); + } + + double _floorMinWidth; + set floorMinWidth(double value) { + if (value == _floorMinWidth) return; + _floorMinWidth = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final RenderBox? child = this.child; + if (child == null) { + size = constraints.smallest; + return; + } + final double? viewport = _port.value; + final double base = + (viewport != null && viewport.isFinite) ? viewport : 0.0; + final double target = math.max(base, _floorMinWidth); + child.layout( + BoxConstraints( + minWidth: target, + maxWidth: target, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + ), + parentUsesSize: true, + ); + size = child.size; + } +} diff --git a/test/flex/intrinsic_safe_layout_test.dart b/test/flex/intrinsic_safe_layout_test.dart new file mode 100644 index 00000000..d34b5400 --- /dev/null +++ b/test/flex/intrinsic_safe_layout_test.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// WIND-4 coverage: the internal `WDiv` flex layout must be intrinsic-safe (no +/// `LayoutBuilder` on any path that can render under an intrinsic-measuring +/// ancestor), an explicit `flex flex-col items-stretch` must equalize child +/// widths, `overflow-x-auto` + `w-full`/`min-w-*` must give a fill-desktop / +/// scroll-narrow primitive, and `h-full` inside a vertical scroll must raise an +/// actionable assert. +Widget wrapWithTheme(Widget child, {double? width, double? height}) { + Widget body = child; + if (width != null || height != null) { + body = SizedBox(width: width, height: height, child: body); + } + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: body), + ), + ); +} + +/// Width of the [RenderBox] that paints the given visible text. +double textWidth(WidgetTester tester, String text) => + tester.renderObject(find.text(text)).size.width; + +void main() { + setUp(WindParser.clearCache); + + group('(a) intrinsic-safe flex under an intrinsic-measuring ancestor', () { + testWidgets( + 'flex flex-col with stretch children renders under ' + 'IntrinsicHeight without the LayoutBuilder-intrinsic assert', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 600, + IntrinsicHeight( + child: WDiv( + className: 'flex flex-col gap-1 p-4', + children: const [ + WDiv(className: 'bg-slate-100', child: Text('Revenue')), + WDiv(className: 'bg-slate-200', child: Text('\$12k')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'flex flex-col with fractional basis child renders under ' + 'IntrinsicHeight without the LayoutBuilder-intrinsic assert', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 600, + IntrinsicHeight( + child: WDiv( + className: 'flex flex-row', + children: const [ + WDiv(className: 'basis-1/2 bg-slate-100', child: Text('L')), + WDiv(className: 'basis-1/2 bg-slate-200', child: Text('R')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'flex flex-col with basis child renders inside an ' + 'items-stretch grid cell without asserting', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 600, + WDiv( + className: 'grid grid-cols-2 gap-4 items-stretch', + children: const [ + WDiv( + className: 'flex flex-col gap-1 p-2', + children: [ + WDiv(className: 'basis-1/2 bg-slate-100', child: Text('a')), + WDiv(className: 'bg-slate-200', child: Text('b')), + ], + ), + WDiv(className: 'p-2', child: Text('c')), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('basis-1/2 in a bounded row still resolves to half width', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 400, + const WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'basis-1/2 h-10 bg-red-500', child: SizedBox()), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 200); + }); + }); + + group('(a) fractional basis degrades gracefully on an unbounded main axis', + () { + testWidgets('basis-1/2 in an unbounded-width row passes through (no crash)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'basis-1/2 bg-red-500', child: Text('x')), + ], + ), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + // Unbounded main axis: the fractional basis degrades to content width. + expect(tester.getSize(find.byType(WDiv).at(1)).width, greaterThan(0)); + }); + + testWidgets('re-layout after a width change keeps the basis fraction', + (tester) async { + Widget tree(double width) => wrapWithTheme( + width: width, + const WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'basis-1/2 h-10 bg-red-500', + child: SizedBox(), + ), + ], + ), + ); + + await tester.pumpWidget(tree(400)); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 200); + // Rebuild with a new width exercises the provider/basis update path. + await tester.pumpWidget(tree(600)); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 300); + }); + }); + + group('(c) explicit flex flex-col items-stretch equalizes child widths', () { + testWidgets('two children of different content width end up equal width', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 300, + const WDiv( + className: 'flex flex-col items-stretch', + children: [ + WDiv(className: 'bg-slate-100', child: Text('short')), + WDiv( + className: 'bg-slate-200', + child: Text('a considerably longer label'), + ), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + final a = tester.getSize(find.byType(WDiv).at(1)).width; + final b = tester.getSize(find.byType(WDiv).at(2)).width; + expect(a, 300); + expect(b, 300); + }); + + testWidgets('items-stretch column does not crash under an unbounded width', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + WDiv( + className: 'flex flex-col items-stretch', + children: [ + WDiv(className: 'bg-red-500', child: Text('content')), + ], + ), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + }); + + group('(b) overflow-x-auto + w-full/min-w-* fill-desktop / scroll-narrow', + () { + testWidgets('fills the container on a wide surface (no scroll overflow)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 720, + WDiv( + className: 'overflow-x-auto', + child: WDiv( + className: 'w-full min-w-[600px] flex flex-row', + children: const [ + WDiv(className: 'flex-1', child: Text('col-a')), + WDiv(className: 'flex-1', child: Text('col-b')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Wide viewport (720 >= 600 floor): content fills the 720px container. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 720); + }); + + testWidgets('scrolls on a narrow surface (content honors the min width)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 300, + WDiv( + className: 'overflow-x-auto', + child: WDiv( + className: 'w-full min-w-[600px] flex flex-row', + children: const [ + WDiv(className: 'flex-1', child: Text('col-a')), + WDiv(className: 'flex-1', child: Text('col-b')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + // Narrow viewport (300 < 600 floor): content is 600px wide and scrolls. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 600); + }); + + testWidgets('w-full without a min-w floor fills the viewport exactly', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 320, + WDiv( + className: 'overflow-x-auto', + child: WDiv( + className: 'w-full flex flex-row', + children: const [ + WDiv(className: 'flex-1', child: Text('only-col')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // No floor: content fills the 320px viewport (base = viewport). + expect(tester.getSize(find.byType(WDiv).at(1)).width, 320); + }); + + testWidgets('re-layout after a viewport change re-resolves the fill width', + (tester) async { + Widget tree(double width) => wrapWithTheme( + width: width, + WDiv( + className: 'overflow-x-auto', + child: WDiv( + className: 'w-full min-w-[600px] flex flex-row', + children: const [ + WDiv(className: 'flex-1', child: Text('c')), + ], + ), + ), + ); + + await tester.pumpWidget(tree(720)); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 720); + await tester.pumpWidget(tree(300)); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 600); + }); + }); + + group('(d) h-full inside a vertical scroll raises an actionable assert', () { + testWidgets('a child with h-full inside overflow-y-auto throws', ( + tester, + ) async { + await tester.pumpWidget( + wrapWithTheme( + width: 300, + height: 400, + const WDiv( + className: 'overflow-y-auto', + children: [ + WDiv(className: 'h-full', child: Text('fill')), + ], + ), + ), + ); + + final Object? error = tester.takeException(); + expect(error, isA()); + expect( + error.toString(), + contains('flex-1'), + ); + }); + + testWidgets('overflow-y-auto with h-full on the SAME element is allowed', + (tester) async { + // The scroll container itself may carry h-full (it reads its parent + // scope, not the scope it installs for its own children). + await tester.pumpWidget( + wrapWithTheme( + height: 300, + WDiv( + className: 'overflow-y-auto h-full', + children: List.generate(30, (i) => WText('Item $i')), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('overflow-auto (both axes) asserts on an h-full child', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + width: 300, + height: 400, + const WDiv( + className: 'overflow-auto', + children: [ + WDiv(className: 'h-full', child: Text('fill')), + ], + ), + ), + ); + + final Object? error = tester.takeException(); + expect(error, isA()); + expect(error.toString(), contains('flex-1')); + }); + }); +} From b90700c2f27e33606e6a856a60ec9df09d10d35c Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 15:29:58 +0300 Subject: [PATCH 2/8] docs(example): add a responsive-table demo for min-width-stretch scroll (WIND-4) --- .../lib/pages/layout/responsive_table.dart | 140 ++++++++++++++++++ example/lib/routes.dart | 2 + 2 files changed, 142 insertions(+) create mode 100644 example/lib/pages/layout/responsive_table.dart diff --git a/example/lib/pages/layout/responsive_table.dart b/example/lib/pages/layout/responsive_table.dart new file mode 100644 index 00000000..50f1e906 --- /dev/null +++ b/example/lib/pages/layout/responsive_table.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +import '../../widgets/example_scaffold.dart'; + +/// Demonstrates the "fill on desktop, scroll on narrow" primitive: an +/// `overflow-x-auto` wrapper around a `w-full min-w-[Npx]` flex table (the +/// shadcn Table pattern), plus the intrinsic-safe `flex flex-col items-stretch` +/// equal-width column. +class ResponsiveTableExamplePage extends StatelessWidget { + const ResponsiveTableExamplePage({super.key}); + + static const _rows = <(String, String, String)>[ + ('Checkout API', 'Operational', '2m ago'), + ('Billing worker', 'Degraded', '5m ago'), + ('Webhook relay', 'Operational', '1m ago'), + ]; + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + title: 'Responsive Table', + description: + 'overflow-x-auto + w-full min-w-[Npx] fills the container when wide ' + 'and scrolls horizontally when narrow, with no unbounded-width crash.', + gradient: 'from-green-500 to-teal-600', + children: [ + ExampleSection( + title: 'Fill on desktop, scroll on narrow', + description: + 'The wrapper is overflow-x-auto; the table is w-full min-w-[560px]. ' + 'Wide viewports fill; narrow viewports scroll horizontally.', + child: WDiv( + className: ''' + overflow-x-auto rounded-lg + border border-slate-200 dark:border-slate-700 + ''', + child: WDiv( + className: 'w-full min-w-[560px] flex flex-col', + children: [ + WDiv( + className: ''' + flex flex-row px-4 py-2 + bg-slate-100 dark:bg-slate-800 + ''', + children: const [ + _HeadCell('Service'), + _HeadCell('Status'), + _HeadCell('Updated'), + ], + ), + for (final row in _rows) + WDiv( + className: ''' + flex flex-row px-4 py-2 + border-t border-slate-200 dark:border-slate-700 + ''', + children: [ + _BodyCell(row.$1), + _BodyCell(row.$2), + _BodyCell(row.$3), + ], + ), + ], + ), + ), + ), + ExampleSection( + title: 'flex flex-col items-stretch', + description: + 'Explicit items-stretch equalizes child widths (every card fills ' + 'the column), matching grid items-stretch and staying crash-free ' + 'under intrinsic-measuring ancestors.', + child: WDiv( + className: 'flex flex-col items-stretch gap-2', + children: const [ + _StretchCard('Short'), + _StretchCard('A considerably longer label than the first'), + ], + ), + ), + ], + ); + } +} + +class _HeadCell extends StatelessWidget { + final String label; + + const _HeadCell(this.label); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex-1', + child: WText( + label, + className: 'font-semibold text-slate-900 dark:text-white', + ), + ); + } +} + +class _BodyCell extends StatelessWidget { + final String value; + + const _BodyCell(this.value); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex-1', + child: WText( + value, + className: 'text-slate-700 dark:text-slate-300', + ), + ); + } +} + +class _StretchCard extends StatelessWidget { + final String label; + + const _StretchCard(this.label); + + @override + Widget build(BuildContext context) { + return WDiv( + className: ''' + p-3 rounded-lg + bg-white dark:bg-slate-800 + border border-slate-200 dark:border-slate-700 + ''', + child: WText( + label, + className: 'text-slate-900 dark:text-white', + ), + ); + } +} diff --git a/example/lib/routes.dart b/example/lib/routes.dart index 60e9cc19..2c847d8e 100644 --- a/example/lib/routes.dart +++ b/example/lib/routes.dart @@ -100,6 +100,7 @@ import 'pages/layout/grid_cols.dart'; import 'pages/layout/grid_gap.dart'; import 'pages/layout/grid_responsive.dart'; import 'pages/layout/responsive.dart'; +import 'pages/layout/responsive_table.dart'; import 'pages/layout/responsive_display.dart'; import 'pages/layout/visibility.dart'; import 'pages/layout/z_index.dart'; @@ -320,6 +321,7 @@ final Map appRoutes = { '/layout/grid_gap': const GridGapExamplePage(), '/layout/grid_responsive': const GridResponsiveExamplePage(), '/layout/responsive': const ResponsiveExamplePage(), + '/layout/responsive_table': const ResponsiveTableExamplePage(), '/layout/responsive_display': const ResponsiveDisplayExamplePage(), '/layout/visibility': const VisibilityExamplePage(), '/layout/z_index': const ZIndexExamplePage(), From b50825149dbd9ae7368e8555dff2efa36f0e9672 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 15:29:58 +0300 Subject: [PATCH 3/8] docs(layout): document intrinsic-safe stretch and min-width scroll (WIND-4) --- CHANGELOG.md | 7 ++++++ doc/layout/flexbox.md | 4 +-- doc/layout/overflow.md | 25 +++++++++++++++++++ skills/wind-ui/SKILL.md | 4 +-- skills/wind-ui/references/layouts.md | 6 +++-- .../wind-ui/references/tailwind-divergence.md | 5 ++-- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f28a0c9..2ba5bbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,15 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- Wind's internal `flex` layout is now fully intrinsic-safe: the column cross-axis stretch and the `basis-*` resolution no longer use a `LayoutBuilder`. Column stretch is a real render object (`WindCrossStretch`) and fractional `basis-*` resolves against the flex's own extent via `WindMainExtentProvider`/`WindFractionBasis`. A `flex flex-col` (with or without `basis-*`) now renders inside an `items-stretch` grid cell, under a Flutter `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without the `LayoutBuilder does not support returning intrinsic dimensions` assert (the web `_owner != null` cascade that produced). (`lib/src/widgets/w_div.dart`, `lib/src/widgets/wind_equal_height_row.dart`) (WIND-4) + ### Added +- Min-width-stretch horizontal scroll primitive ("fill on desktop, scroll on narrow", the shadcn Table pattern), composed from existing tokens with no new className: `overflow-x-auto` on a wrapper with `w-full` (optionally `min-w-[Npx]`) on the inner content. `w-full` inside a horizontal scroll is now sized to `max(viewport, min-w-*)` via the threaded viewport width instead of asserting on the scroll's unbounded width, so the content fills the viewport when wide and honors its min width (scrolling) when narrow. (`lib/src/widgets/wind_min_width_scroll.dart`, `lib/src/state/wind_min_width_scroll_scope.dart`, `lib/src/widgets/w_div.dart`, `example/lib/pages/layout/responsive_table.dart`) (WIND-4) +- Explicit `flex flex-col items-stretch` now equalizes child widths (every eligible child fills the column), closing the asymmetry with `grid ... items-stretch`. Like the smart-stretch default it is intrinsic-safe and unbounded-safe (no `LayoutBuilder`, no infinite-width `SizedBox`), so it also works in a bare `Row` slot or under an intrinsic-measuring ancestor. (`lib/src/widgets/w_div.dart`, `lib/src/widgets/wind_equal_height_row.dart`) (WIND-4) +- Actionable dev-time assert for `h-full` inside a vertical scroll: a child that resolves `h-full` under an `overflow-y-auto`/`overflow-y-scroll` parent (an unbounded height) now fails fast with a message pointing at the fix (`flex-1` inside a `flex flex-col`), instead of a cryptic Flutter unbounded-height error. The scrollable parent threads the signal down via `WindMinWidthScrollScope`; the assert is stripped in release. (`lib/src/widgets/w_div.dart`, `lib/src/state/wind_min_width_scroll_scope.dart`) (WIND-4) - `cursor-*` utilities: a new `CursorParser` maps Tailwind cursor names (`cursor-pointer`, `cursor-not-allowed`, `cursor-text`, `cursor-grab`/`cursor-grabbing`, `cursor-zoom-in`/`cursor-zoom-out`, the full resize set, etc.) to the matching `SystemMouseCursors`. `WDiv` applies the resolved cursor through a `MouseRegion`, so a plain container can feel clickable on web/desktop without `WAnchor` or a manual `MouseRegion`; on a `hover:`/`focus:`/`active:` `WDiv` the cursor `MouseRegion` sits below the auto-wrapped `WAnchor` and wins. Inert on touch platforms; last token wins; unknown names no-op. (`lib/src/parser/parsers/cursor_parser.dart`, `lib/src/parser/wind_style.dart`, `lib/src/widgets/w_div.dart`) (#125) - `size-*` sizing utility (Tailwind v3.4+ shorthand): `size-2`, `size-full`, `size-1/2`, `size-[20px]`, `size-[50%]`, `size-screen` set BOTH width and height in one token. Fixes childless `WDiv` sizing: a `WDiv(className: 'size-2 rounded-full bg-...')` status dot now renders its box instead of collapsing (the previous gap was that `size-*` was unrecognized, not that `w-*`/`h-*` were ignored, which already worked childless). A later `w-*`/`h-*` overrides the matching axis. (`lib/src/parser/parsers/sizing_parser.dart`) (#123) - `grid` equal-height rows via `items-stretch`. By default a Wind `grid` renders as a `Wrap` and sizes each cell to its own content, leaving a row ragged when one card is taller. Adding `items-stretch` (CSS Grid's default `align-items: stretch`) now builds the grid as a column of `IntrinsicHeight` rows so every cell in a row matches the tallest, and cells divide the row width evenly. Cells should size from their own content; `h-full` on a stretched cell is unsupported (it inserts a `LayoutBuilder` that asserts under `IntrinsicHeight`, see the intrinsic-sizing limitation docs). (`lib/src/widgets/w_div.dart`) (#126) diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index c29331c5..17c588dd 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -208,9 +208,9 @@ Controls how children are distributed along the **cross axis** (Vertical for `ro WDiv(className: 'flex items-center h-20') ``` -> **Column default: smart cross-axis stretch.** A `flex flex-col` with no explicit `items-*` token 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`. 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`), 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 any `items-*` token (e.g. `items-start`) to turn this 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. +> **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: avoid `IntrinsicHeight` / `IntrinsicWidth` in animated subtrees.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout. Inside a sheet or route open animation (e.g. `DraggableScrollableSheet`, `PageRouteBuilder`) this lands a `RenderBox was not laid out` assertion because the animation drives a frame before intrinsics resolve. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, use a `Stack` with a `Positioned(top: 0, bottom: 0)` line instead, or use wind's own `items-stretch` column which is intrinsic-free and animation-safe. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`; its smart column stretch is `LayoutBuilder` + `SizedBox(width: double.infinity)` (see `lib/src/widgets/w_div.dart`). +> **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). > > ```dart > // Safe connector pattern: Stack + Positioned, no IntrinsicHeight diff --git a/doc/layout/overflow.md b/doc/layout/overflow.md index 2df2c4df..d71e880d 100644 --- a/doc/layout/overflow.md +++ b/doc/layout/overflow.md @@ -99,6 +99,31 @@ WDiv(className: 'overflow-x-scroll overflow-y-hidden') | `overflow-x-hidden` | Clip horizontal overflow | | `overflow-y-visible` | Allow vertical overflow | + +## Fill on Desktop, Scroll on Narrow (Responsive Table) + +Compose `overflow-x-auto` on a wrapper with `w-full` (optionally `min-w-[Npx]`) on the inner content, the same pattern shadcn's `` uses. The inner content fills the viewport when it is wide and honors its minimum width (so the wrapper scrolls) when it is narrow: + +```dart +WDiv( + className: 'overflow-x-auto', + child: WDiv( + className: 'w-full min-w-[600px] flex flex-row', + children: [ + WDiv(className: 'flex-1', child: WText('Name')), + WDiv(className: 'flex-1', child: WText('Status')), + WDiv(className: 'flex-1', child: WText('Updated')), + ], + ), +) +``` + +On a viewport wider than `600px` the row fills the container (no horizontal scroll). On a narrower viewport it stays `600px` wide and the wrapper scrolls horizontally. This works with no new token: `w-full` inside a horizontal scroll is threaded the viewport width instead of asserting on the scroll's unbounded width, and `min-w-[Npx]` sets the scroll floor. Without a `min-w-*` floor, `w-full` simply fills the viewport. + + + +> **`h-full` inside a vertical scroll is a layout error.** A child that resolves `h-full` inside an `overflow-y-auto` / `overflow-y-scroll` parent has an unbounded height and produces a cryptic Flutter failure. Wind raises an actionable assert in debug pointing at the fix: use `flex-1` inside a `flex flex-col` (with the scroll on the column) instead of `h-full` inside a vertical scroll. The scroll container itself may still carry `h-full` (it is bounded by its own parent). + ## Responsive Design diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 9eea9e76..612a1a07 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -3,10 +3,10 @@ name: wind-ui description: "fluttersdk_wind 1.1: utility-first Flutter styling with Tailwind-syntax className strings. 27 public widgets (WDiv, WText, WButton, WInput, WSelect, WCheckbox, WDatePicker, WPopover, WAnchor, WIcon, WImage, WSvg, WSpacer, WBreakpoint, WDynamic, WKeyboardActions, WindAnimationWrapper, WBadge, WCard, WSwitch, WRadio, WTabs + 5 WForm* wrappers) consume className through a 20-parser pipeline (20 implementation files organized into 12 token families for teaching) that emits a cached immutable WindStyle. WindRecipe / WindSlotRecipe compose className variants (base + axes + compoundVariants + caller) in strict emission order, no dedupe/sort/twMerge. Prefixes stack freely (dark: / hover: / focus: / md: / lg: / ios: / android: / web: / mobile: / selected: / loading: / disabled: / readonly: / error: / checked: / custom). Last class wins; unknown tokens fail silently. Every color token (bg-, text-, border-, ring-, shadow-, fill-) needs a dark: pair in the same className. TRIGGER when: writing or editing any UI in a Flutter app that depends on `fluttersdk_wind`; any className string; any W-prefix widget; any WindTheme / WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend / API / state-management work that does not touch a widget tree; Flutter projects that do not have fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) without Wind content inside them." when_to_use: | Any task that produces, modifies, or audits Wind-styled Flutter UI: composing a className string, picking the right W-widget for a use case, integrating with a Form / FormField, customizing WindThemeData, wiring dark-mode pairs, debugging an unexpected layout, recovering from RenderFlex overflow, building a popover or dropdown, rendering a JSON tree via WDynamic, wiring Wind.installDebugResolver for kDebugMode tooling, migrating a Tailwind className from web, or composing a WindRecipe / WindSlotRecipe for a variant-driven component. Apply BEFORE writing the first line of UI in a Wind-using file, not as an audit pass. -version: 2.8.2 +version: 2.9.0 --- - + # Wind UI 1.1 diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md index 9983ecb9..db21a61e 100644 --- a/skills/wind-ui/references/layouts.md +++ b/skills/wind-ui/references/layouts.md @@ -381,9 +381,11 @@ WDiv( ); ``` -`IntrinsicHeight` is documented as relatively expensive (it runs a speculative layout pass). Reach for it only when this exact stretch-inside-scroll pattern is required. +`IntrinsicHeight` is documented as relatively expensive (it runs a speculative layout pass). Reach for it only when this exact row height-match-inside-scroll pattern is required. -> **Caveat — intrinsic sizing fails through `h-full` / `basis-*` content.** If a child inside the `IntrinsicHeight` triggers a Wind internal `LayoutBuilder` — `h-full` in an unbounded-height context, or a flex `basis-*` (which wraps the surrounding flex in one `LayoutBuilder`) — the intrinsic pass throws `LayoutBuilder does not support returning intrinsic dimensions` (a Flutter constraint, not a Wind bug). Keep the children explicitly sized (`flex-1` for width is fine; avoid `h-full` / `basis-*` under an `IntrinsicHeight`). For equal-height cards outside a scroll, prefer fixed `h-*` cells or a `Stack` + `Positioned(top:0,bottom:0)` over `IntrinsicHeight`. +> **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)`. --- diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md index 1dd338fd..a6e32e9e 100644 --- a/skills/wind-ui/references/tailwind-divergence.md +++ b/skills/wind-ui/references/tailwind-divergence.md @@ -42,8 +42,9 @@ The base rule: Wind aims for syntactic familiarity, not semantic equivalence. Mo | `text-7xl`+ | Larger sizes available | No-op | | `text-{color}` | Pure font color | Overloaded across color / alignment / size / weight — resolves in order | | `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%) | Triggers "Vertical viewport unbounded"; restructure | -| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | **Do not wrap animated subtrees in these.** They perform an intrinsic-dimension pass mid-layout and raise `RenderBox was not laid out` asserts inside sheet/route open animations (e.g. `DraggableScrollableSheet`). For a connector or rail that must fill the cross axis, use a `Stack` + `Positioned(top: 0, bottom: 0)` line, or wind's `items-stretch` column (intrinsic-free; `LayoutBuilder` + `SizedBox(width: double.infinity)` internally, see `lib/src/widgets/w_div.dart`). Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. | +| `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`. | | `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 | From 1ce3a57936f4bb8febeda08a4b6857ecdef177f8 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 17:35:29 +0300 Subject: [PATCH 4/8] fix(layout): degrade WindMinWidthBox to content width on an unbounded viewport When the scroll viewport width is unpublished (null) or infinite (the scroll sits in an unbounded-width context), the box no longer forces a tight (possibly 0) width that collapses a w-full child. It now lays the child out with the min-w-* floor as a lower bound and content width as the upper, mirroring WindCrossStretch / WindFractionBasis. Adds direct RenderObject tests for the finite / floor / null / infinite viewport cases. --- lib/src/widgets/wind_min_width_scroll.dart | 23 +++++- test/flex/wind_min_width_scroll_test.dart | 89 ++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 test/flex/wind_min_width_scroll_test.dart diff --git a/lib/src/widgets/wind_min_width_scroll.dart b/lib/src/widgets/wind_min_width_scroll.dart index 39f51fe5..9e3c5956 100644 --- a/lib/src/widgets/wind_min_width_scroll.dart +++ b/lib/src/widgets/wind_min_width_scroll.dart @@ -166,9 +166,26 @@ class _RenderMinWidthBox extends RenderProxyBox { return; } final double? viewport = _port.value; - final double base = - (viewport != null && viewport.isFinite) ? viewport : 0.0; - final double target = math.max(base, _floorMinWidth); + if (viewport == null || !viewport.isFinite) { + // No finite viewport width to fill against (the scroll sits in an + // unbounded-width context, or the width has not been published yet). + // Degrade to the child's content width honoring the min-w-* floor, + // instead of forcing a tight (possibly 0) width that would collapse it. + // This mirrors how WindCrossStretch / WindFractionBasis pass through on + // an unbounded axis. + child.layout( + BoxConstraints( + minWidth: _floorMinWidth, + maxWidth: double.infinity, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + ), + parentUsesSize: true, + ); + size = child.size; + return; + } + final double target = math.max(viewport, _floorMinWidth); child.layout( BoxConstraints( minWidth: target, diff --git a/test/flex/wind_min_width_scroll_test.dart b/test/flex/wind_min_width_scroll_test.dart new file mode 100644 index 00000000..09d45d02 --- /dev/null +++ b/test/flex/wind_min_width_scroll_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/widgets/wind_min_width_scroll.dart'; + +/// WIND-4 min-width-stretch scroll: `WindMinWidthBox` sizes a `w-full` child to +/// `max(viewportWidth, min-w-*)` when the scroll viewport width is known, and +/// must degrade to the child's content width (honoring the `min-w-*` floor) +/// rather than collapsing to 0 when the viewport is unknown or unbounded. +Widget host(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: Align(alignment: Alignment.topLeft, child: child), + ); + +void main() { + testWidgets('fills a finite published viewport width', (tester) async { + final port = WindViewportWidthPort()..value = 400; + await tester.pumpWidget( + host( + WindMinWidthBox( + port: port, + floorMinWidth: 0, + child: const SizedBox(width: 50, height: 20), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Viewport 400 wins over the 50px content: the child fills the viewport. + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 400); + }); + + testWidgets('honors the min-w floor over a narrower viewport', + (tester) async { + final port = WindViewportWidthPort()..value = 120; + await tester.pumpWidget( + host( + WindMinWidthBox( + port: port, + floorMinWidth: 300, + child: const SizedBox(width: 50, height: 20), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Floor 300 > viewport 120: the child takes the floor (so the scroll scrolls). + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 300); + }); + + testWidgets( + 'degrades to content width (not 0) when the viewport is unpublished', + (tester) async { + // Regression: a null viewport with no floor used to force a tight 0 width, + // collapsing the content. It must degrade to the child's content width. + final port = WindViewportWidthPort(); // value == null + await tester.pumpWidget( + host( + WindMinWidthBox( + port: port, + floorMinWidth: 0, + child: const SizedBox(width: 120, height: 20), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 120); + }); + + testWidgets( + 'degrades to the floor when the viewport is infinite (unbounded scroll)', + (tester) async { + final port = WindViewportWidthPort()..value = double.infinity; + await tester.pumpWidget( + host( + WindMinWidthBox( + port: port, + floorMinWidth: 200, + child: const SizedBox(width: 120, height: 20), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Infinite viewport: no fill target, so the child takes its content width + // clamped up to the min-w floor (200), never a tight 0. + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 200); + }); +} From a1074762d46a0c516532c1e97ae422855d58038d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 17:35:29 +0300 Subject: [PATCH 5/8] test(flex): drop the unused textWidth helper from the intrinsic-safe suite --- test/flex/intrinsic_safe_layout_test.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/flex/intrinsic_safe_layout_test.dart b/test/flex/intrinsic_safe_layout_test.dart index d34b5400..39c905c9 100644 --- a/test/flex/intrinsic_safe_layout_test.dart +++ b/test/flex/intrinsic_safe_layout_test.dart @@ -21,10 +21,6 @@ Widget wrapWithTheme(Widget child, {double? width, double? height}) { ); } -/// Width of the [RenderBox] that paints the given visible text. -double textWidth(WidgetTester tester, String text) => - tester.renderObject(find.text(text)).size.width; - void main() { setUp(WindParser.clearCache); From cd848282962603f415ad4a5529f8c42f2a94a5a1 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 17:51:48 +0300 Subject: [PATCH 6/8] fix(layout): constrain WindMinWidthBox to the incoming width constraints The unbounded/unpublished-viewport degradation laid the child out with maxWidth double.infinity and reported size = child.size, which could exceed a finite parent constraint (box used outside a scroll, or before a finite viewport publishes) and trip 'size not within constraints'. Both branches now clamp the floor/target into the incoming width range and re-constrain the reported size. Adds a finite-parent regression test. --- lib/src/widgets/wind_min_width_scroll.dart | 21 +++++++++++++------ test/flex/wind_min_width_scroll_test.dart | 24 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/lib/src/widgets/wind_min_width_scroll.dart b/lib/src/widgets/wind_min_width_scroll.dart index 9e3c5956..0481ba90 100644 --- a/lib/src/widgets/wind_min_width_scroll.dart +++ b/lib/src/widgets/wind_min_width_scroll.dart @@ -172,20 +172,29 @@ class _RenderMinWidthBox extends RenderProxyBox { // Degrade to the child's content width honoring the min-w-* floor, // instead of forcing a tight (possibly 0) width that would collapse it. // This mirrors how WindCrossStretch / WindFractionBasis pass through on - // an unbounded axis. + // an unbounded axis. The floor is clamped into the INCOMING width range + // (and the size re-constrained) so the box still respects a finite parent + // constraint if it is ever laid out outside a scroll. + final double childMin = + _floorMinWidth.clamp(constraints.minWidth, constraints.maxWidth); child.layout( BoxConstraints( - minWidth: _floorMinWidth, - maxWidth: double.infinity, + minWidth: childMin, + maxWidth: constraints.maxWidth, minHeight: constraints.minHeight, maxHeight: constraints.maxHeight, ), parentUsesSize: true, ); - size = child.size; + size = constraints.constrain(child.size); return; } - final double target = math.max(viewport, _floorMinWidth); + // Fill the viewport, honoring the min-w-* floor. `target` is clamped into + // the incoming width range so the tight layout can never exceed a finite + // parent constraint. + final double target = math + .max(viewport, _floorMinWidth) + .clamp(constraints.minWidth, constraints.maxWidth); child.layout( BoxConstraints( minWidth: target, @@ -195,6 +204,6 @@ class _RenderMinWidthBox extends RenderProxyBox { ), parentUsesSize: true, ); - size = child.size; + size = constraints.constrain(child.size); } } diff --git a/test/flex/wind_min_width_scroll_test.dart b/test/flex/wind_min_width_scroll_test.dart index 09d45d02..a98f8a57 100644 --- a/test/flex/wind_min_width_scroll_test.dart +++ b/test/flex/wind_min_width_scroll_test.dart @@ -86,4 +86,28 @@ void main() { // clamped up to the min-w floor (200), never a tight 0. expect(tester.getSize(find.byType(WindMinWidthBox)).width, 200); }); + + testWidgets('respects a finite parent width instead of violating constraints', + (tester) async { + // If the box is ever laid out under a finite width (outside a scroll, or + // before a finite viewport is published), it must not size past the + // incoming constraint even when the child wants more. + final port = WindViewportWidthPort(); // value == null + await tester.pumpWidget( + host( + SizedBox( + width: 300, + child: WindMinWidthBox( + port: port, + floorMinWidth: 0, + child: const SizedBox(width: 500, height: 20), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Child wants 500 but the parent only allows 300: the box stays at 300. + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 300); + }); } From 7def632163e84536ed7457f1635848ecb1cb5817 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 17:51:48 +0300 Subject: [PATCH 7/8] chore(layout): correct the stale basis LayoutBuilder comment and a duplicate test-group label The w_div basis comment still described the removed LayoutBuilder path; it now points at WindMainExtentProvider. The second flex test group no longer reuses the '(a)' label. --- lib/src/widgets/w_div.dart | 7 ++++--- test/flex/intrinsic_safe_layout_test.dart | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 81eadee2..5442d893 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -543,9 +543,10 @@ class WDiv extends StatelessWidget { // `basis-*` resolves a fraction/fixed size against the flex's own bounded // main extent. Flex hands non-flex children an unbounded main-axis - // constraint, so a fractional box cannot self-size — we measure the extent - // with a LayoutBuilder around the whole flex and pass it down. Only taken - // when a child actually carries `basis-*`, so the common case is unwrapped. + // constraint, so a fractional box cannot self-size; the flex publishes its + // own extent to the box at layout time (see WindMainExtentProvider below), + // with no LayoutBuilder. Only taken when a child actually carries `basis-*`, + // so the common case is unwrapped. final bool hasBasisChild = _anyChildHasBasis(gappedChildren); if (hasBasisChild) { // Resolve `basis-*` intrinsic-safely: fixed `basis-[Npx]` becomes a diff --git a/test/flex/intrinsic_safe_layout_test.dart b/test/flex/intrinsic_safe_layout_test.dart index 39c905c9..3ce12332 100644 --- a/test/flex/intrinsic_safe_layout_test.dart +++ b/test/flex/intrinsic_safe_layout_test.dart @@ -113,8 +113,7 @@ void main() { }); }); - group('(a) fractional basis degrades gracefully on an unbounded main axis', - () { + group('fractional basis degrades gracefully on an unbounded main axis', () { testWidgets('basis-1/2 in an unbounded-width row passes through (no crash)', (tester) async { await tester.pumpWidget( From d18241609d44f58d0c6f0ef459e5fa207f8cdc42 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 7 Jul 2026 18:36:13 +0300 Subject: [PATCH 8/8] test(flex): cover the WIND-4 render-object branches for patch coverage Directly exercises WindCrossStretch (bounded + unbounded), the column WindMainExtentProvider / WindFractionBasis basis path, a basis-factor and a flex-direction rebuild (render-object update path), the WindMinWidthBox floor setter, and WindMinWidthScrollScope.updateShouldNotify. Lifts patch coverage of the new render objects; the few remaining lines are unreachable null-child guards. --- test/flex/wind_min_width_scroll_test.dart | 56 ++++++++++ test/flex/wind_render_objects_test.dart | 127 ++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 test/flex/wind_render_objects_test.dart diff --git a/test/flex/wind_min_width_scroll_test.dart b/test/flex/wind_min_width_scroll_test.dart index a98f8a57..ef7f3ec1 100644 --- a/test/flex/wind_min_width_scroll_test.dart +++ b/test/flex/wind_min_width_scroll_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/state/wind_min_width_scroll_scope.dart'; import 'package:fluttersdk_wind/src/widgets/wind_min_width_scroll.dart'; /// WIND-4 min-width-stretch scroll: `WindMinWidthBox` sizes a `w-full` child to @@ -110,4 +111,59 @@ void main() { // Child wants 500 but the parent only allows 300: the box stays at 300. expect(tester.getSize(find.byType(WindMinWidthBox)).width, 300); }); + + testWidgets('re-lays out when the min-w floor changes', (tester) async { + final port = WindViewportWidthPort()..value = 50; + Widget tree(double floor) => host( + WindMinWidthBox( + port: port, + floorMinWidth: floor, + child: const SizedBox(width: 20, height: 20), + ), + ); + + await tester.pumpWidget(tree(100)); + // Floor 100 > viewport 50. + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 100); + // Same widget position, new floor: exercises the floorMinWidth setter. + await tester.pumpWidget(tree(250)); + expect(tester.getSize(find.byType(WindMinWidthBox)).width, 250); + }); + + test('WindMinWidthScrollScope.updateShouldNotify tracks port and axis flag', + () { + const child = SizedBox(); + final portA = WindViewportWidthPort(); + final portB = WindViewportWidthPort(); + final base = WindMinWidthScrollScope( + horizontalPort: portA, + child: child, + ); + + // Same port and flag: no rebuild. + expect( + base.updateShouldNotify( + WindMinWidthScrollScope(horizontalPort: portA, child: child), + ), + isFalse, + ); + // Different port: rebuild. + expect( + base.updateShouldNotify( + WindMinWidthScrollScope(horizontalPort: portB, child: child), + ), + isTrue, + ); + // Different vertical-unbounded flag: rebuild. + expect( + base.updateShouldNotify( + WindMinWidthScrollScope( + horizontalPort: portA, + verticalUnbounded: true, + child: child, + ), + ), + isTrue, + ); + }); } diff --git a/test/flex/wind_render_objects_test.dart b/test/flex/wind_render_objects_test.dart new file mode 100644 index 00000000..d2fe2cf8 --- /dev/null +++ b/test/flex/wind_render_objects_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/widgets/wind_equal_height_row.dart'; + +/// WIND-4 render-object coverage: the intrinsic-free `WindCrossStretch` +/// (cross-axis stretch) and the `WindMainExtentProvider` / `WindFractionBasis` +/// pair (fractional `basis-*` resolved against the flex's own extent) exercised +/// directly and through a `flex flex-col` so both the bounded/unbounded stretch +/// branches and the column (vertical main-axis) basis path are covered. +Widget host(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: Align(alignment: Alignment.topLeft, child: child), + ); + +Widget wrapWind(Widget child, {double? width, double? height}) => MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: SizedBox(width: width, height: height, child: child), + ), + ), + ); + +void main() { + setUp(WindParser.clearCache); + + group('WindCrossStretch', () { + testWidgets('stretches the child to a bounded incoming width', + (tester) async { + await tester.pumpWidget( + host( + const SizedBox( + width: 300, + child: WindCrossStretch(child: SizedBox(width: 20, height: 10)), + ), + ), + ); + + expect(tester.getSize(find.byType(WindCrossStretch)).width, 300); + }); + + testWidgets('passes the child through at content width when unbounded', + (tester) async { + await tester.pumpWidget( + host( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + WindCrossStretch(child: SizedBox(width: 42, height: 10)), + ], + ), + ), + ); + + expect(tester.getSize(find.byType(WindCrossStretch)).width, 42); + }); + }); + + group('column fractional basis (WindMainExtentProvider / WindFractionBasis)', + () { + testWidgets('basis-1/2 resolves against the column height', (tester) async { + await tester.pumpWidget( + wrapWind( + height: 400, + const WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: 'basis-1/2 w-10 bg-red-500', child: SizedBox()), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + // Half of a 400px-tall column. + expect(tester.getSize(find.byType(WDiv).at(1)).height, 200); + }); + + testWidgets('rebuilding with a new basis factor re-lays the child', + (tester) async { + Widget tree(String basis) => wrapWind( + height: 400, + WDiv( + className: 'flex flex-col', + children: [ + WDiv( + className: '$basis w-10 bg-red-500', + child: const SizedBox(), + ), + ], + ), + ); + + await tester.pumpWidget(tree('basis-1/2')); + expect(tester.getSize(find.byType(WDiv).at(1)).height, 200); + // Same widget position, new factor: exercises the render-object update + // path (factor/port setters) rather than a fresh create. + await tester.pumpWidget(tree('basis-1/4')); + expect(tester.getSize(find.byType(WDiv).at(1)).height, 100); + }); + + testWidgets('flipping the flex direction re-lays the basis child', + (tester) async { + Widget tree(String direction) => wrapWind( + width: 400, + height: 400, + WDiv( + className: 'flex $direction', + children: [ + WDiv( + className: 'basis-1/2 bg-red-500', + child: const SizedBox(width: 10, height: 10), + ), + ], + ), + ); + + // Column: basis sizes height. Flipping to row (same widget position) + // exercises the isColumn setters on the extent provider and fraction box. + await tester.pumpWidget(tree('flex-col')); + expect(tester.takeException(), isNull); + await tester.pumpWidget(tree('flex-row')); + expect(tester.takeException(), isNull); + }); + }); +}