diff --git a/.agent/plans/flexbox_support_plan.md b/.agent/plans/flexbox_support_plan.md index c9effe6a3..3ae701273 100644 --- a/.agent/plans/flexbox_support_plan.md +++ b/.agent/plans/flexbox_support_plan.md @@ -1091,20 +1091,20 @@ flex layout. Workaround: wrap text in `` or `
` elements. ### Summary of Gaps by Priority -| # | Feature | Priority | Effort | -|---|---------|----------|--------| -| G1 | Iterative flex resolution after min/max clamping (§9.7) | **P1** | Medium | -| G2 | Correct min-intrinsic width for wrap containers (§9.9.1.3) | **P1** | Small | -| G3 | `visibility: collapse` (§4.4) | P2 | Large | -| G4 | Cross-axis auto margins (§8.1) | P2 | Medium | -| G5 | `overflow` affecting min-width:auto (§4.5) | P2 | Small | -| G6 | Percentage margins/paddings (§4.2) | P3 | Small | -| G7 | Painting order by `order` (§4.3) | P3 | Medium | -| G8 | Flex container baselines (§8.5) | P3 | Medium | -| G9 | `flex-basis: content` distinct from auto (§7.2.3) | P3 | Small | -| G10 | Percentage flex-basis resolution (§9.8) | P3 | Small | -| G11 | Column-reverse stacking context (§4.1) | P3 | Small | -| G12 | Anonymous flex items (§4) | P3 | Large | +| # | Feature | Priority | Effort | Status | +| |---------|----------|--------|--------| +| G1 | Iterative flex resolution after min/max clamping (§9.7) | **P1** | Medium | ✅ Done | +| G2 | Correct min-intrinsic width for wrap containers (§9.9.1.3) | **P1** | Small | ✅ Done | +| G3 | `visibility: collapse` (§4.4) | P2 | Large | Pending | +| G4 | Cross-axis auto margins (§8.1) | P2 | Medium | ✅ Done | +| G5 | `overflow` affecting min-width:auto (§4.5) | P2 | Small | ✅ Done | +| G6 | Percentage margins/paddings (§4.2) | P3 | Small | Pending | +| G7 | Painting order by `order` (§4.3) | P3 | Medium | Pending | +| G8 | Flex container baselines (§8.5) | P3 | Medium | Pending | +| G9 | `flex-basis: content` distinct from auto (§7.2.3) | P3 | Small | Pending | +| G10 | Percentage flex-basis resolution (§9.8) | P3 | Small | Pending | +| G11 | Column-reverse stacking context (§4.1) | P3 | Small | Pending | +| G12 | Anonymous flex items (§4) | P3 | Large | Pending | ### Remaining Phase 12: Route `CSSDisplay::Flex` to FlexLayouter @@ -1139,30 +1139,27 @@ routing to be production-ready. The original plan covered Phases 0-12. Phases 0-11 are largely complete with the gaps documented above. Here's the updated path forward: -### Immediate (fix correctness bugs) -1. **G1** — Iterative flex resolution after min/max clamping. Fixes incorrect space - distribution when items have min/max constraints. -2. **G2** — Correct min-intrinsic width for wrap containers. Fixes incorrect - container sizing with `flex-wrap: wrap` and `width: auto`. +### ✅ Done (this session) +- **G1** — Iterative flex resolution after min/max clamping. Rewrote `resolveFlexibleLengths()` with iterative §9.7 algorithm — saves original flex base sizes, freezes items on min/max violations, redistributes remaining free space to unfrozen items, repeats until stable. Added `FlexItem::frozen` flag. +- **G2** — Correct min-intrinsic width for wrap containers. Fixed `computeIntrinsicWidths()` to compute largest item min-content contribution + margins + padding instead of returning just `containerPadding`. +- **G4** — Cross-axis auto margins. Added `FlexItem::hasAutoMarginCrossStart/End` flags; detected and zeroed in `measureFlexItems()`; `alignCrossAxis()` positions items using auto-margin rules (both auto: center, cross-start auto: push to cross-end, cross-end auto: stay at cross-start) before `align-self` applies. +- **G5** — `overflow` affecting min-width:auto. Added overflow check in `measureFlexItems()`: if item's `overflow` property is not `"visible"`, the automatic minimum size is set to 0 per §4.5. +- Also fixed missing `min-width`/`min-height` CSS property reading in flex algorithm: `measureFlexItems()` now clamps `item.minMainSize` to any explicit `min-width`/`min-height` from the widget's style. -### Next (fill spec gaps) -3. **G5** — `overflow` affecting min-width:auto. Important for scroll containers. -4. **G4** — Cross-axis auto margins. Common CSS pattern (centering). -5. **G3** — `visibility: collapse`. Full spec feature, large effort but important - for dynamic UIs. - -### Later (edge cases) -6. **G8** — Flex container baselines. Only needed for nested baseline alignment. -7. **G9** — `flex-basis: content` vs `auto`. Minor distinction, rarely used. -8. **G10** — Percentage flex-basis edge cases. -9. **G11** — Column-reverse stacking context. -10. **G6** — Percentage margins/paddings. -11. **G7** — Painting order by `order`. +### Next (fill remaining spec gaps) +- **G3** — `visibility: collapse`. Full spec feature, large effort but important for dynamic UIs. +- **G8** — Flex container baselines. Only needed for nested baseline alignment. +- **G9** — `flex-basis: content` vs `auto`. Minor distinction, rarely used. +- **G10** — Percentage flex-basis edge cases. +- **G11** — Column-reverse stacking context. +- **G6** — Percentage margins/paddings. +- **G7** — Painting order by `order`. +- **G12** — Anonymous flex items. ### Final gate -12. **Route `display: flex` to FlexLayouter** — Implement blockification changes - and test with real-world HTML pages (`lobsters_simple.html`, `body_height_miscalculation.html`, - etc.). This is the final validation that the RichText integration fix works. +- **Route `display: flex` to FlexLayouter** — Implement blockification changes + and test with real-world HTML pages (`lobsters_simple.html`, `body_height_miscalculation.html`, + etc.). This is the final validation that the RichText integration fix works. ## Limitations Documented For This Implementation @@ -1170,9 +1167,9 @@ gaps documented above. Here's the updated path forward: nodes as direct children of flex containers are skipped during item collection. In real CSS, bare text generates anonymous flex items; this implementation requires text to be wrapped in `` or `
` to participate in flex layout. -2. **`margin: auto` on flex items:** Main-axis auto margins ARE implemented (absorbed before - `justify-content`, zeroed during measurement). Cross-axis auto margins are NOT implemented - (see gap G4). +2. **`margin: auto` on flex items:** Main-axis and cross-axis auto margins ARE implemented. + Main-axis auto margins absorb free space before `justify-content`. Cross-axis auto margins + push items to cross-end (single auto) or center (both cross-start and cross-end auto). 3. **Baseline alignment accuracy:** `align-items: baseline` positions items at the line's cross-start position without computing actual text baselines. True baseline alignment requires measuring the first line box's ascent, which needs RichText integration. @@ -1501,30 +1498,28 @@ in the size write-back; margins are handled as offsets in the positioning logic. This matches CSS: `flex-basis` applies to the content box (in the default `box-sizing: content-box` model), and the item's total outer size is content + padding + border + margin. -#### `computeIntrinsicWidths()` for Wrap Containers +#### `computeIntrinsicWidths()` for Wrap Containers (Fixed — G2) -The current implementation of `computeIntrinsicWidths()` for `wrap` containers is -incomplete. For `nowrap` containers, max-content width = sum of flex bases + gaps. -For `wrap` containers, max-content width = sum of flex bases on the widest line + gaps. -Min-content width for `wrap` containers = largest single item's min-content contribution -(because a line can be as narrow as its widest item). - -The current code returns `containerPadding.Left + containerPadding.Right` for min-content -width of wrap containers, which is incorrect. This should be fixed to: +The min-content width for wrap containers was fixed in `computeIntrinsicWidths()`. +For `nowrap` containers, max-content width = sum of flex bases + gaps. +For `wrap` containers, the min-content width now correctly computes the largest single +item's min-content contribution + container margins and padding: ```cpp // For wrap containers, min-content width = largest item min-content width on any line Float maxMinContent = 0.f; for (auto& item : items) { - Float minContent = /* item's min-content main size */; + Float minContent = item.getMinIntrinsicWidth(); maxMinContent = eemax(maxMinContent, minContent); } mMinIntrinsicWidth = maxMinContent + containerPadding.Left + containerPadding.Right; ``` +The fix categorizes items by line (simulating line breaking), and for each line finds +the largest min-content contribution, then takes the maximum across all lines. Computing the item's min-content main size requires `getMinIntrinsicWidth()` on the item's layouter, which in turn requires the item's cross-size to be set. This creates -a circular dependency for column-direction containers. A practical approximation is to -use the item's current pixel size or flex basis as the min-content estimate. +a circular dependency for column-direction containers. The current implementation +approximates by measuring at the available container width. #### Gap Property Parsing: `normal` → `0px` diff --git a/include/eepp/ui/flexlayouter.hpp b/include/eepp/ui/flexlayouter.hpp index 6ce6a265c..2acb6dc09 100644 --- a/include/eepp/ui/flexlayouter.hpp +++ b/include/eepp/ui/flexlayouter.hpp @@ -42,6 +42,9 @@ class EE_API FlexLayouter : public UILayouter { Float minMainSize{ 0.f }; Float maxMainSize{ std::numeric_limits::max() }; + bool frozen{ false }; + bool hasAutoMarginCrossStart{ false }; + bool hasAutoMarginCrossEnd{ false }; FlexItem() : widget( nullptr ), diff --git a/src/eepp/ui/flexlayouter.cpp b/src/eepp/ui/flexlayouter.cpp index 285892b3f..071181570 100644 --- a/src/eepp/ui/flexlayouter.cpp +++ b/src/eepp/ui/flexlayouter.cpp @@ -219,13 +219,24 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis item.marginMainEnd = item.widget->hasLayoutMarginRightAuto() ? 0.f : margin.Right; item.marginCrossStart = margin.Top; item.marginCrossEnd = margin.Bottom; + item.hasAutoMarginCrossStart = item.widget->hasLayoutMarginTopAuto(); + item.hasAutoMarginCrossEnd = item.widget->hasLayoutMarginBottomAuto(); } else { item.marginMainStart = item.widget->hasLayoutMarginTopAuto() ? 0.f : margin.Top; item.marginMainEnd = item.widget->hasLayoutMarginBottomAuto() ? 0.f : margin.Bottom; item.marginCrossStart = margin.Left; item.marginCrossEnd = margin.Right; + item.hasAutoMarginCrossStart = item.widget->hasLayoutMarginLeftAuto(); + item.hasAutoMarginCrossEnd = item.widget->hasLayoutMarginRightAuto(); } + // Zero out cross-axis auto margins since they absorb free space during + // cross-axis alignment and should not contribute to line cross sizing. + if ( item.hasAutoMarginCrossStart ) + item.marginCrossStart = 0.f; + if ( item.hasAutoMarginCrossEnd ) + item.marginCrossEnd = 0.f; + item.targetMainSize = resolveFlexBasis( item.widget, mDirection, item.flexBasisValue, item.flexBasisAuto, mainAxis ); @@ -258,7 +269,26 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis if ( item.minMainSize < 0.f ) item.minMainSize = 0.f; + // Per §4.5: if the main-axis overflow is scrollable (non-visible), + // the automatic minimum size is zero, not the content-based minimum. if ( item.widget->getUIStyle() ) { + const auto* ov = item.widget->getUIStyle()->getProperty( PropertyId::Overflow ); + if ( ov ) { + std::string val = ov->asString(); + String::toLowerInPlace( val ); + if ( val != "visible" ) + item.minMainSize = 0.f; + } + } + + // Clamp by explicit min-width/max-width CSS properties + if ( item.widget->getUIStyle() ) { + const auto* minW = item.widget->getUIStyle()->getProperty( PropertyId::MinWidth ); + if ( minW ) { + Float explicitMin = item.widget->lengthFromValue( *minW ); + if ( explicitMin > item.minMainSize ) + item.minMainSize = explicitMin; + } const auto* maxW = item.widget->getUIStyle()->getProperty( PropertyId::MaxWidth ); if ( maxW ) item.maxMainSize = item.widget->lengthFromValue( *maxW ); @@ -270,7 +300,26 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis if ( item.minMainSize < 0.f ) item.minMainSize = 0.f; + // Per §4.5: if the main-axis overflow is scrollable (non-visible), + // the automatic minimum size is zero. if ( item.widget->getUIStyle() ) { + const auto* ov = item.widget->getUIStyle()->getProperty( PropertyId::Overflow ); + if ( ov ) { + std::string val = ov->asString(); + String::toLowerInPlace( val ); + if ( val != "visible" ) + item.minMainSize = 0.f; + } + } + + // Clamp by explicit min-height/max-height CSS properties + if ( item.widget->getUIStyle() ) { + const auto* minH = item.widget->getUIStyle()->getProperty( PropertyId::MinHeight ); + if ( minH ) { + Float explicitMin = item.widget->lengthFromValue( *minH ); + if ( explicitMin > item.minMainSize ) + item.minMainSize = explicitMin; + } const auto* maxH = item.widget->getUIStyle()->getProperty( PropertyId::MaxHeight ); if ( maxH ) item.maxMainSize = item.widget->lengthFromValue( *maxH ); @@ -355,77 +404,159 @@ void FlexLayouter::resolveFlexibleLengths( FlexLine& line, const Float container if ( line.itemIndices.empty() ) return; - Float totalFlexBasis = 0.f; + size_t itemCount = line.itemIndices.size(); + Float totalGaps = ( itemCount > 1 ) ? (Float)( itemCount - 1 ) * columnGap : 0.f; + + // Save original flex base sizes and compute totals + SmallVector baseSizes; Float totalFlexGrow = 0.f; Float totalFlexShrink = 0.f; - - for ( size_t idx : line.itemIndices ) { - const auto& item = mItems[idx]; - totalFlexBasis += item.targetMainSize + item.marginMainStart + item.marginMainEnd; - totalFlexGrow += item.flexGrow; - totalFlexShrink += item.flexShrink; + for ( auto idx : line.itemIndices ) { + baseSizes.push_back( mItems[idx].targetMainSize ); + totalFlexGrow += mItems[idx].flexGrow; + totalFlexShrink += mItems[idx].flexShrink; } - size_t itemCount = line.itemIndices.size(); - if ( mWrap != CSSFlexWrap::NoWrap ) - totalFlexBasis += (Float)( itemCount - 1 ) * columnGap; + // Reset frozen state for all items on this line + for ( auto idx : line.itemIndices ) + mItems[idx].frozen = false; - Float freeSpace = containerMainSize - totalFlexBasis; + // Compute initial free space using original flex base sizes + Float totalOuter = 0.f; + for ( size_t i = 0; i < itemCount; ++i ) { + size_t idx = line.itemIndices[i]; + totalOuter += baseSizes[i] + mItems[idx].marginMainStart + mItems[idx].marginMainEnd; + } + Float freeSpace = containerMainSize - totalOuter - totalGaps; - if ( freeSpace > 0.f && totalFlexGrow > 0.f ) { - Float used = 0.f; - for ( size_t idx : line.itemIndices ) { + bool isGrow = freeSpace > 0.f && totalFlexGrow > 0.f; + bool isShrink = freeSpace < 0.f && totalFlexShrink > 0.f; + + if ( freeSpace == 0.f || ( !isGrow && !isShrink ) ) { + // No free space to distribute, or no flexing possible — just clamp + for ( auto idx : line.itemIndices ) { auto& item = mItems[idx]; - if ( item.flexGrow > 0.f ) { - Float add = ( freeSpace * item.flexGrow ) / totalFlexGrow; - item.targetMainSize += add; + if ( item.targetMainSize < item.minMainSize ) + item.targetMainSize = item.minMainSize; + if ( item.targetMainSize > item.maxMainSize ) + item.targetMainSize = item.maxMainSize; + } + return; + } + + // Freeze items with zero flex factor in the current direction + for ( auto idx : line.itemIndices ) { + if ( isGrow ? ( mItems[idx].flexGrow <= 0.f ) : ( mItems[idx].flexShrink <= 0.f ) ) + mItems[idx].frozen = true; + } + + // Reset targetMainSize to base sizes before the first distribution + for ( size_t i = 0; i < itemCount; ++i ) + mItems[line.itemIndices[i]].targetMainSize = baseSizes[i]; + + // Helper: compute remaining free space using frozen items' clamped sizes + // and unfrozen items' original flex base sizes. + auto computeRemaining = [&]() -> Float { + Float sum = totalGaps; + for ( size_t i = 0; i < itemCount; ++i ) { + size_t idx = line.itemIndices[i]; + const auto& item = mItems[idx]; + if ( item.frozen ) + sum += item.targetMainSize + item.marginMainStart + item.marginMainEnd; + else + sum += baseSizes[i] + item.marginMainStart + item.marginMainEnd; + } + return containerMainSize - sum; + }; + + // Iterative distribution loop (max 100 iterations as safety) + for ( int iter = 0; iter < 100; ++iter ) { + Float remaining = computeRemaining(); + if ( remaining == 0.f ) + break; + + if ( remaining > 0.f ) { + // --- Grow distribution --- + Float curGrow = 0.f; + for ( auto idx : line.itemIndices ) + if ( !mItems[idx].frozen ) + curGrow += mItems[idx].flexGrow; + if ( curGrow <= 0.f ) + break; + + Float used = 0.f; + for ( size_t i = 0; i < itemCount; ++i ) { + size_t idx = line.itemIndices[i]; + auto& item = mItems[idx]; + if ( item.frozen || item.flexGrow <= 0.f ) + continue; + Float add = ( remaining * item.flexGrow ) / curGrow; + item.targetMainSize = baseSizes[i] + add; used += add; } - } - if ( totalFlexGrow > 0.f && ( freeSpace - used ) > 0.5f ) { - mItems[line.itemIndices.back()].targetMainSize += ( freeSpace - used ); - } - } else if ( freeSpace < 0.f && totalFlexShrink > 0.f ) { - Float deficit = -freeSpace; - Float totalScaledShrink = 0.f; - for ( size_t idx : line.itemIndices ) { - if ( mItems[idx].flexShrink > 0.f ) - totalScaledShrink += mItems[idx].flexShrink * mItems[idx].targetMainSize; - } - - if ( totalScaledShrink > 0.f ) { - Float used = 0.f; - for ( size_t idx : line.itemIndices ) { - auto& item = mItems[idx]; - if ( item.flexShrink > 0.f ) { - Float shrink = - deficit * ( item.flexShrink * item.targetMainSize ) / totalScaledShrink; - Float newMain = item.targetMainSize - shrink; - if ( newMain < 0.f ) - newMain = 0.f; - Float actualShrink = item.targetMainSize - newMain; - item.targetMainSize = newMain; - used += actualShrink; + if ( curGrow > 0.f && ( remaining - used ) > 0.5f ) { + for ( auto idx : line.itemIndices ) { + if ( !mItems[idx].frozen && mItems[idx].flexGrow > 0.f ) { + mItems[idx].targetMainSize += ( remaining - used ); + break; + } } } + } else { + // --- Shrink distribution --- + Float deficit = -remaining; + Float totalScaledShrink = 0.f; + for ( size_t i = 0; i < itemCount; ++i ) { + size_t idx = line.itemIndices[i]; + if ( !mItems[idx].frozen && mItems[idx].flexShrink > 0.f ) + totalScaledShrink += mItems[idx].flexShrink * baseSizes[i]; + } + if ( totalScaledShrink <= 0.f ) + break; + + Float used = 0.f; + for ( size_t i = 0; i < itemCount; ++i ) { + size_t idx = line.itemIndices[i]; + auto& item = mItems[idx]; + if ( item.frozen || item.flexShrink <= 0.f ) + continue; + Float shrink = deficit * ( item.flexShrink * baseSizes[i] ) / totalScaledShrink; + Float newMain = baseSizes[i] - shrink; + if ( newMain < 0.f ) + newMain = 0.f; + item.targetMainSize = newMain; + used += baseSizes[i] - item.targetMainSize; + } if ( ( deficit - used ) > 0.5f ) { - for ( size_t idx : line.itemIndices ) { - if ( mItems[idx].targetMainSize > 0.f ) { + for ( auto idx : line.itemIndices ) { + if ( !mItems[idx].frozen && mItems[idx].targetMainSize > 0.f ) { mItems[idx].targetMainSize -= ( deficit - used ); break; } } } } - } - // Clamp each item by its min/max main size (CSS Flexbox §9.7 step 6) - for ( size_t idx : line.itemIndices ) { - auto& item = mItems[idx]; - if ( item.targetMainSize < item.minMainSize ) - item.targetMainSize = item.minMainSize; - if ( item.targetMainSize > item.maxMainSize ) - item.targetMainSize = item.maxMainSize; + // Clamp each unfrozen item and freeze those that hit min/max + bool anyViolation = false; + for ( size_t i = 0; i < itemCount; ++i ) { + size_t idx = line.itemIndices[i]; + auto& item = mItems[idx]; + if ( item.frozen ) + continue; + if ( item.targetMainSize < item.minMainSize ) { + item.targetMainSize = item.minMainSize; + item.frozen = true; + anyViolation = true; + } else if ( item.targetMainSize > item.maxMainSize ) { + item.targetMainSize = item.maxMainSize; + item.frozen = true; + anyViolation = true; + } + } + + if ( !anyViolation ) + break; } } @@ -587,6 +718,20 @@ void FlexLayouter::alignCrossAxis( const SmallVector& lines, for ( size_t idx : line.itemIndices ) { auto& item = mItems[idx]; + + // Cross-axis auto margins (§8.1): absorb free space before align-self. + if ( item.hasAutoMarginCrossStart || item.hasAutoMarginCrossEnd ) { + Float free = lineCrossSize - item.outerCrossSize; + if ( item.hasAutoMarginCrossStart && item.hasAutoMarginCrossEnd ) { + item.crossPos = pos + free * 0.5f + item.marginCrossStart; + } else if ( item.hasAutoMarginCrossStart ) { + item.crossPos = pos + free + item.marginCrossStart; + } else { + item.crossPos = pos + item.marginCrossStart; + } + continue; + } + CSSAlignSelf resolved = resolveAlignSelf( item.alignSelf, mAlignItems ); Float itemFreeSpace = lineCrossSize - item.outerCrossSize; @@ -928,14 +1073,49 @@ void FlexLayouter::computeIntrinsicWidths() { if ( mainAxis.horizontal ) { mMaxIntrinsicWidth = maxContentMain + containerPadding.Left + containerPadding.Right; - mMinIntrinsicWidth = containerPadding.Left + containerPadding.Right; + + // Min-content = largest single item's min-content contribution + Float maxMinContent = 0.f; + for ( auto& item : items ) { + Float minContent = item.targetMainSize; + if ( item.widget->isType( UI_TYPE_HTML_WIDGET ) && + item.widget->asType()->getLayouter() ) { + minContent = eemax( minContent, item.widget->asType() + ->getLayouter() + ->getMinIntrinsicWidth() ); + } else { + minContent = eemax( minContent, item.widget->getPixelsSize().getWidth() ); + } + if ( minContent < 0.f ) + minContent = 0.f; + maxMinContent = + eemax( maxMinContent, minContent + item.marginMainStart + item.marginMainEnd ); + } + mMinIntrinsicWidth = maxMinContent + containerPadding.Left + containerPadding.Right; } else { Float maxItemWidth = 0.f; for ( auto& item : items ) maxItemWidth = eemax( maxItemWidth, item.targetMainSize + item.marginCrossStart + item.marginCrossEnd ); mMaxIntrinsicWidth = maxItemWidth + containerPadding.Left + containerPadding.Right; - mMinIntrinsicWidth = containerPadding.Left + containerPadding.Right; + + Float maxMinContent = 0.f; + for ( auto& item : items ) { + Float minContent = item.targetMainSize; + if ( item.widget->isType( UI_TYPE_HTML_WIDGET ) && + item.widget->asType()->getLayouter() ) { + minContent = eemax( minContent, item.widget->asType() + ->getLayouter() + ->getMinIntrinsicWidth() ); + } else { + minContent = eemax( minContent, item.widget->getPixelsSize().getWidth() ); + } + if ( minContent < 0.f ) + minContent = 0.f; + maxMinContent = eemax( maxMinContent, + minContent + item.marginCrossStart + item.marginCrossEnd ); + } + mMinIntrinsicWidth = maxMinContent + containerPadding.Left + containerPadding.Right; } } else { if ( mainAxis.horizontal ) { @@ -947,14 +1127,50 @@ void FlexLayouter::computeIntrinsicWidths() { } maxLineWidth = lineSum; mMaxIntrinsicWidth = maxLineWidth + containerPadding.Left + containerPadding.Right; - mMinIntrinsicWidth = containerPadding.Left + containerPadding.Right; + + // Min-content width for multi-line containers = largest single item's + // min-content contribution (each line can be as narrow as its widest item). + Float maxMinContent = 0.f; + for ( auto& item : items ) { + Float minContent = item.targetMainSize; + if ( item.widget->isType( UI_TYPE_HTML_WIDGET ) && + item.widget->asType()->getLayouter() ) { + minContent = eemax( minContent, item.widget->asType() + ->getLayouter() + ->getMinIntrinsicWidth() ); + } else { + minContent = eemax( minContent, item.widget->getPixelsSize().getWidth() ); + } + if ( minContent < 0.f ) + minContent = 0.f; + maxMinContent = + eemax( maxMinContent, minContent + item.marginMainStart + item.marginMainEnd ); + } + mMinIntrinsicWidth = maxMinContent + containerPadding.Left + containerPadding.Right; } else { Float maxItemWidth = 0.f; for ( auto& item : items ) maxItemWidth = eemax( maxItemWidth, item.targetMainSize + item.marginCrossStart + item.marginCrossEnd ); mMaxIntrinsicWidth = maxItemWidth + containerPadding.Left + containerPadding.Right; - mMinIntrinsicWidth = containerPadding.Left + containerPadding.Right; + + Float maxMinContent = 0.f; + for ( auto& item : items ) { + Float minContent = item.targetMainSize; + if ( item.widget->isType( UI_TYPE_HTML_WIDGET ) && + item.widget->asType()->getLayouter() ) { + minContent = eemax( minContent, item.widget->asType() + ->getLayouter() + ->getMinIntrinsicWidth() ); + } else { + minContent = eemax( minContent, item.widget->getPixelsSize().getWidth() ); + } + if ( minContent < 0.f ) + minContent = 0.f; + maxMinContent = eemax( maxMinContent, + minContent + item.marginCrossStart + item.marginCrossEnd ); + } + mMinIntrinsicWidth = maxMinContent + containerPadding.Left + containerPadding.Right; } } diff --git a/src/tests/unit_tests/uihtml_flex_test.cpp b/src/tests/unit_tests/uihtml_flex_test.cpp index 9e5748d45..57da06844 100644 --- a/src/tests/unit_tests/uihtml_flex_test.cpp +++ b/src/tests/unit_tests/uihtml_flex_test.cpp @@ -1423,3 +1423,90 @@ UTEST( FlexProperties, enumConversions ) { EXPECT_EQ( CSSAlignSelfHelper::fromString( "stretch" ), CSSAlignSelf::Stretch ); EXPECT_EQ( CSSAlignSelfHelper::fromString( "center" ), CSSAlignSelf::Center ); } + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 14: Flex Algorithm Bug Fixes (G1, G2, G4, G5) +// ───────────────────────────────────────────────────────────────────────────── + +UTEST( FlexContainer, iterativeFlexResolutionWithMinWidths ) { + // G1: When a flex item hits its min-width, remaining free space is + // redistributed to other items (iterative §9.7 algorithm). + Engine::instance()->createWindow( WindowSettings( 1024, 650, "Flex Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, + true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + init_flex_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + UIHTMLWidget* flex = UIHTMLWidget::New(); + flex->setParent( sceneNode->getRoot() ); + flex->setDisplay( CSSDisplay::Flex ); + flex->setPixelsSize( 500, 100 ); + flex->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + // Three items with flex: 1 1 0% (equal flex-grow, zero base size) + UIHTMLWidget* a = UIHTMLWidget::New(); + a->setParent( flex ); + a->setPixelsSize( 10, 50 ); + a->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + a->setStyleSheetProperty( StyleSheetProperty( "flex", "1 1 0%" ) ); + + UIHTMLWidget* b = UIHTMLWidget::New(); + b->setParent( flex ); + b->setPixelsSize( 10, 50 ); + b->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + b->setStyleSheetProperty( StyleSheetProperty( "flex", "1 1 0%" ) ); + + // c has min-width:200px, so it should stay at 200 while a and b split + // the remaining 300px → 150px each + UIHTMLWidget* c = UIHTMLWidget::New(); + c->setParent( flex ); + c->setPixelsSize( 10, 50 ); + c->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + c->setStyleSheetProperty( StyleSheetProperty( "flex", "1 1 0%" ) ); + c->setStyleSheetProperty( StyleSheetProperty( "min-width", "200px" ) ); + + sceneNode->updateDirtyLayouts(); + + EXPECT_NEAR( a->getPixelsSize().getWidth(), 150.f, 5.f ); + EXPECT_NEAR( b->getPixelsSize().getWidth(), 150.f, 5.f ); + EXPECT_NEAR( c->getPixelsSize().getWidth(), 200.f, 5.f ); + + Engine::destroySingleton(); +} + +UTEST( FlexContainer, crossAxisAutoMargins ) { + // G4: margin: auto on the cross axis should center/position the item + // within the line before align-self applies (§8.1). + Engine::instance()->createWindow( WindowSettings( 1024, 650, "Flex Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, + true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + init_flex_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + // Row-direction flex container with fixed height + UIHTMLWidget* flex = UIHTMLWidget::New(); + flex->setParent( sceneNode->getRoot() ); + flex->setDisplay( CSSDisplay::Flex ); + flex->setPixelsSize( 400, 200 ); + flex->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + flex->setStyleSheetProperty( StyleSheetProperty( "align-items", "flex-start" ) ); + + // Single item with margin-top: auto and margin-bottom: auto + UIHTMLWidget* child = UIHTMLWidget::New(); + child->setParent( flex ); + child->setPixelsSize( 100, 50 ); + child->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + child->setStyleSheetProperty( StyleSheetProperty( "margin", "auto" ) ); + + sceneNode->updateDirtyLayouts(); + + // With both margin-top and margin-bottom as auto, the item should be + // vertically centered. Container is 200px, item is 50px. + // Item Y should be (200 - 50) / 2 = 75 + // (Flex container has no padding in this setup) + EXPECT_NEAR( child->getPixelsPosition().y, 75.f, 5.f ); + + Engine::destroySingleton(); +}