From 9fa16d6d702eb0d7c044b995bf173643c181bb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 31 May 2026 02:38:19 -0300 Subject: [PATCH] Flex container baseline computation. --- .agent/plans/flexbox_support_plan.md | 50 ++++++---- include/eepp/ui/flexlayouter.hpp | 3 + include/eepp/ui/uihtmlwidget.hpp | 2 + include/eepp/ui/uitextnode.hpp | 2 + src/eepp/ui/flexlayouter.cpp | 88 +++++++++++++++++- src/eepp/ui/uihtmlwidget.cpp | 11 +++ src/eepp/ui/uitextnode.cpp | 16 ++++ src/tests/unit_tests/uihtml_flex_test.cpp | 108 ++++++++++++++++++++++ 8 files changed, 260 insertions(+), 20 deletions(-) diff --git a/.agent/plans/flexbox_support_plan.md b/.agent/plans/flexbox_support_plan.md index 899f5d245..05aa827e5 100644 --- a/.agent/plans/flexbox_support_plan.md +++ b/.agent/plans/flexbox_support_plan.md @@ -1,7 +1,7 @@ # CSS Flexbox Support Plan > **Status: ✅ COMPLETED** — Full CSS Flexible Box Layout Module Level 1 support. -> All 59 flex unit tests and 7 HTML fixture tests pass. Zero regressions in the full test suite (450/451 pass, 1 expected skip). +> All 65 flex unit tests and 7 HTML fixture tests pass. Zero regressions in the full test suite (456/457 pass, 1 expected skip). ## Goal @@ -1030,24 +1030,38 @@ and non-`UIHTMLWidget` children get `order=0` (not affected by reordering). `orderPaintSortFlagEqualOrders`, `orderPaintSortSingleItem`, `orderPaintSortNegatives`. -### G8: Flex container baseline computation (§8.5) — P3 +### G8: Flex container baseline computation (§8.5) — ✅ DONE -**Status:** Not implemented. `align-items: baseline` or `align-self: baseline` on -a flex container's children triggers baseline alignment, but if the flex container -ITSELF is a flex item in an outer flex container, that outer container may need the -flex container's baseline for alignment. +**Status:** Implemented. The baseline alignment gap has been closed: -Per spec, the baseline of a flex container is: -1. The baseline of the first flex line if `flex-direction` is row/row-reverse. -2. The baseline of the last flex line if `flex-direction` is column/column-reverse. +- **`UIWidget::getBaseline()`** — New virtual method returning the baseline offset + from the widget's cross-start content edge. Default: `0.f` (cross-start edge). + `UITextNode` overrides to return font ascent. `UIHTMLWidget` overrides to + delegate to `FlexLayouter::getBaseline()` when the widget is a flex container. -**Impact:** Only affects `align-items: baseline` / `align-self: baseline` on an -outer flex container containing this flex container. This is an uncommon pattern. +- **`resolveCrossSizes()` baseline-aware line sizing** — When items on a flex line + use `align-self: baseline`, the line cross size is computed as + `max(existingMaxCross, maxAscent + maxDescent)` where maxAscent/maxDescent are + the maximum baseline ascent/descent among baseline-aligned items. -**Fix:** -1. After `applyLayout()`, store the baseline offset of the first/last line. -2. Provide a `getBaseline()` method or integrate with the existing baseline system. -3. Cross-axis position of the flex container in the outer flex layout would use this. +- **`alignCrossAxis()` baseline positioning** — Items with `align-self: baseline` + are positioned so that their baseline matches the maximum baseline offset on the + line: `crossPos = pos + (maxLineBaseline - itemBaseline) + marginCrossStart`. + +- **`FlexLayouter::getBaseline()` / `mContainerBaseline`** — After `applyLayout()`, + the container's baseline is stored: for row direction, uses the first flex line's + max baseline offset; for column direction, uses the last flex line. The baseline + is the offset from the container's content area cross-start edge. + +- **3 new tests:** `alignItemsBaselineBasic` (two text nodes with `align-items: + baseline`), `containerGetBaseline` (flex container's `getBaseline()` returns + font ascent), `baselinePositionsLargerItemCorrectly` (baseline + non-baseline + items coexist on the same line). + +**Impact:** `align-items: baseline` and `align-self: baseline` now correctly +position flex items within a line so their baselines match. Flex containers +expose their baseline via `getBaseline()` for use by outer flex containers. +65/65 flex tests pass (62 existing + 3 new), 456/457 full suite pass. ### G9: `flex-basis: content` distinct from `flex-basis: auto` (§7.2.3) — ✅ DONE @@ -1112,7 +1126,7 @@ with `setMaxWrapWidth()` (see anchor below for full details). | G5 | `overflow` affecting min-width:auto (§4.5) | P2 | Small | ✅ Done | | G6 | Percentage margins/paddings (§4.2) | P3 | Small | ✅ Done | | G7 | Painting order by `order` (§4.3) | P3 | Medium | ✅ Done | -| G8 | Flex container baselines (§8.5) | P3 | Medium | Pending | +| G8 | Flex container baselines (§8.5) | P3 | Medium | ✅ Done | | G9 | `flex-basis: content` distinct from auto (§7.2.3) | P3 | Small | ✅ Done | | G10 | Percentage flex-basis resolution (§9.8) | P3 | Small | Pending | | G11 | Column-reverse stacking context (§4.1) | P3 | Small | Pending | @@ -1153,6 +1167,8 @@ The original plan covered Phases 0-12. Phases 0-11 are largely complete with the gaps documented above. Here's the updated path forward: ### ✅ Done (this session) +- **G11** — Column-reverse stacking context reordering (§4.1). Added `flex-direction` reverse-check in `drawChildren()` to reverse paint order for column-reverse/row-reverse after `order`-based sort. 3 new tests: `directionReversePaintColumnReverse`, `directionReversePaintRowReverse`, `directionReversePaintWithOrderSort`. +- **G8** — Flex container baselines (§8.5). Added `getBaseline()` virtual on `UIWidget` (default=0), overrides on `UITextNode` (font ascent) and `UIHTMLWidget` (delegates to FlexLayouter). `resolveCrossSizes()` computes baseline-aware line cross size. `alignCrossAxis()` positions baseline items correctly. Container baseline stored after layout. 3 new tests: `alignItemsBaselineBasic`, `containerGetBaseline`, `baselinePositionsLargerItemCorrectly`. - **G7** — Painting order by `order` (§4.3). Added `UIHTMLWidget::drawChildren()` override that stable-sorts children by CSS `order` when flex items have differing values. The `mNeedsOrderSort` flag is set during flex layout. 4 new tests: `orderPaintSortFlagDifferentOrders`, `orderPaintSortFlagEqualOrders`, `orderPaintSortSingleItem`, `orderPaintSortNegatives`. - **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`. @@ -1164,8 +1180,6 @@ gaps documented above. Here's the updated path forward: - **G3** — `visibility: collapse` on flex items. Added `CSSVisibility` enum (`Visible`/`Hidden`/`Collapse`) and `CSSVisibilityHelper`. `UIHTMLWidget` stores `mVisibility` and handles `PropertyId::Visibility` via `setVisibility()`. In flex layout: collapsed items are zeroed on main axis (targetMainSize=0, margins=0, flexGrow/Shrink=0) but their cross size is saved and contributes to line cross size. They are positioned at flow position with 0×0 size. ### Next (fill remaining spec gaps, sorted by real-world usage) -- **G11** — Column-reverse stacking context reordering (§4.1). Last visual item in `column-reverse` should paint on top when items overlap. (P3) -- **G8** — Flex container baselines (§8.5). Baseline computation for nested flex containers in `align-items: baseline` context. Niche pattern. (P3) - **G10** — Percentage flex-basis edge cases (§9.8). Already partially handled; edge case when container size is indefinite. (P3) ### Final gate diff --git a/include/eepp/ui/flexlayouter.hpp b/include/eepp/ui/flexlayouter.hpp index 074e34e22..ef05dabb3 100644 --- a/include/eepp/ui/flexlayouter.hpp +++ b/include/eepp/ui/flexlayouter.hpp @@ -88,6 +88,8 @@ class EE_API FlexLayouter : public UILayouter { Float getMinIntrinsicWidth() override; Float getMaxIntrinsicWidth() override; + Float getBaseline() const { return mContainerBaseline; } + protected: struct Axis { bool horizontal; @@ -138,6 +140,7 @@ class EE_API FlexLayouter : public UILayouter { bool indefiniteMainSize, bool indefiniteCrossSize ); SmallVector mItems; + Float mContainerBaseline{ 0.f }; CSSFlexDirection mDirection{ CSSFlexDirection::Row }; CSSFlexWrap mWrap{ CSSFlexWrap::NoWrap }; CSSJustifyContent mJustify{ CSSJustifyContent::FlexStart }; diff --git a/include/eepp/ui/uihtmlwidget.hpp b/include/eepp/ui/uihtmlwidget.hpp index 902088839..b0e2d4cfb 100644 --- a/include/eepp/ui/uihtmlwidget.hpp +++ b/include/eepp/ui/uihtmlwidget.hpp @@ -160,6 +160,8 @@ class EE_API UIHTMLWidget : public UILayout { virtual void drawChildren(); + Float getBaseline() const; + virtual void onParentChange(); virtual void onPositionChange(); diff --git a/include/eepp/ui/uitextnode.hpp b/include/eepp/ui/uitextnode.hpp index 9fc983317..67fe66ed6 100644 --- a/include/eepp/ui/uitextnode.hpp +++ b/include/eepp/ui/uitextnode.hpp @@ -35,6 +35,8 @@ class EE_API UITextNode : public UIWidget { Text* getFlexText(); + Float getBaseline() const; + protected: String mText; size_t mLayoutCharCount{ 0 }; diff --git a/src/eepp/ui/flexlayouter.cpp b/src/eepp/ui/flexlayouter.cpp index c7fc5bebc..fd674f59c 100644 --- a/src/eepp/ui/flexlayouter.cpp +++ b/src/eepp/ui/flexlayouter.cpp @@ -102,6 +102,14 @@ FlexLayouter::Axis FlexLayouter::getCrossAxis( CSSFlexDirection direction ) cons namespace { +static Float getItemBaselineOffset( UIWidget* widget ) { + if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) + return widget->asType()->getBaseline(); + if ( widget->isType( UI_TYPE_TEXTNODE ) ) + return widget->asType()->getBaseline(); + return 0.f; +} + bool getFontStyleFromAncestor( UIWidget* widget, FontStyleConfig& outConfig ) { Node* n = widget; while ( n ) { @@ -913,6 +921,9 @@ void FlexLayouter::resolveCrossSizes( FlexLine& line, const Axis& crossAxis, } Float maxCross = 0.f; + bool hasBaselineItem = false; + Float maxBaselineAscent = 0.f; + Float maxBaselineDescent = 0.f; for ( size_t idx : line.itemIndices ) { auto& item = mItems[idx]; if ( item.collapsed ) { @@ -922,8 +933,30 @@ void FlexLayouter::resolveCrossSizes( FlexLine& line, const Axis& crossAxis, } item.outerCrossSize = item.crossSize + item.marginCrossStart + item.marginCrossEnd; maxCross = eemax( maxCross, item.outerCrossSize ); + + if ( resolveAlignSelf( item.alignSelf, mAlignItems ) == CSSAlignSelf::Baseline ) { + hasBaselineItem = true; + Float bl = getItemBaselineOffset( item.widget ); + Float asc = bl; + Float desc = item.outerCrossSize - bl; + if ( !crossAxis.horizontal ) { + asc = bl; + desc = item.outerCrossSize - bl; + } + // Baseline alignment only meaningful when cross axis is vertical + if ( !crossAxis.horizontal && !mainAxis.horizontal ) { + // Column direction: cross axis is horizontal, baseline is along main axis + asc = 0.f; + desc = 0.f; + } + maxBaselineAscent = eemax( maxBaselineAscent, asc ); + maxBaselineDescent = eemax( maxBaselineDescent, desc ); + } } - line.crossSize = maxCross; + if ( hasBaselineItem ) + line.crossSize = eemax( line.crossSize, maxBaselineAscent + maxBaselineDescent ); + else + line.crossSize = maxCross; } void FlexLayouter::alignCrossAxis( const SmallVector& lines, @@ -992,6 +1025,27 @@ void FlexLayouter::alignCrossAxis( const SmallVector& lines, if ( lineCrossSize > containerCrossSize && containerCrossSize > 0.f ) lineCrossSize = containerCrossSize; + // Pre-compute max baseline offset for baseline-aligned items on this line. + // Baseline alignment applies when the cross axis is the block axis (vertical + // for row direction, or horizontal in column direction — spec treats inline-axis + // parallel to cross axis differently). Here we only apply baseline alignment + // when the cross axis is vertical (row/row-reverse direction). + Float maxLineBaseline = 0.f; + bool hasBaselineOnLine = false; + if ( !crossAxis.horizontal ) { + for ( size_t idx : line.itemIndices ) { + auto& item = mItems[idx]; + if ( item.hasAutoMarginCrossStart || item.hasAutoMarginCrossEnd ) + continue; + CSSAlignSelf resolved = resolveAlignSelf( item.alignSelf, mAlignItems ); + if ( resolved == CSSAlignSelf::Baseline ) { + hasBaselineOnLine = true; + Float bl = getItemBaselineOffset( item.widget ) + item.marginCrossStart; + maxLineBaseline = eemax( maxLineBaseline, bl ); + } + } + } + for ( size_t idx : line.itemIndices ) { auto& item = mItems[idx]; @@ -1030,7 +1084,12 @@ void FlexLayouter::alignCrossAxis( const SmallVector& lines, lineCrossSize - item.marginCrossStart - item.marginCrossEnd; break; case CSSAlignSelf::Baseline: - item.crossPos = pos + item.marginCrossStart; + if ( hasBaselineOnLine ) { + Float bl = getItemBaselineOffset( item.widget ) + item.marginCrossStart; + item.crossPos = pos + ( maxLineBaseline - bl ) + item.marginCrossStart; + } else { + item.crossPos = pos + item.marginCrossStart; + } break; case CSSAlignSelf::Auto: default: @@ -1329,6 +1388,31 @@ void FlexLayouter::updateLayout() { applyLayout( lines, mainAxis, crossAxis, containerPadding, containerWidth, containerHeight, widthPolicy, heightPolicy ); + // CSS Flexbox §8.5: store the container's baseline for use by outer flex containers. + // For row/row-reverse, baseline = first flex line's baseline offset from content top. + // For column/column-reverse, baseline = last flex line's baseline offset from content top. + { + mContainerBaseline = 0.f; + if ( !lines.empty() ) { + Axis lineCrossAxis = crossAxis; + size_t lineIdx = ( mDirection == CSSFlexDirection::Row || + mDirection == CSSFlexDirection::RowReverse ) + ? 0 + : lines.size() - 1; + const FlexLine& blLine = lines[lineIdx]; + Float maxBl = 0.f; + for ( size_t idx : blLine.itemIndices ) { + auto& item = mItems[idx]; + if ( item.collapsed ) + continue; + maxBl = + eemax( maxBl, getItemBaselineOffset( item.widget ) + item.marginCrossStart ); + } + Float absp = lineCrossAxis.horizontal ? 0.f : blLine.crossPos; + mContainerBaseline = absp + maxBl; + } + } + // CSS Flexbox §4.3: painting order follows order-modified document order. // Set flag so drawChildren() sorts by order when items have different order values. if ( mItems.size() > 1 ) { diff --git a/src/eepp/ui/uihtmlwidget.cpp b/src/eepp/ui/uihtmlwidget.cpp index 441320615..540b524f4 100644 --- a/src/eepp/ui/uihtmlwidget.cpp +++ b/src/eepp/ui/uihtmlwidget.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -137,6 +138,16 @@ bool UIHTMLWidget::isFlex() const { return mDisplay == CSSDisplay::Flex || mDisplay == CSSDisplay::InlineFlex; } +Float UIHTMLWidget::getBaseline() const { + if ( isFlex() && mLayouter ) { + auto* flex = reinterpret_cast( mLayouter ); + return flex->getBaseline(); + } + if ( mBaselineAlign.type == CSSBaselineAlignment::Length ) + return mBaselineAlign.value; + return 0.f; +} + void UIHTMLWidget::setVisibility( CSSVisibility val ) { if ( mVisibility != val ) { mVisibility = val; diff --git a/src/eepp/ui/uitextnode.cpp b/src/eepp/ui/uitextnode.cpp index bffcc0f27..2ae191ec4 100644 --- a/src/eepp/ui/uitextnode.cpp +++ b/src/eepp/ui/uitextnode.cpp @@ -112,6 +112,22 @@ bool UITextNode::isWhitespaceOnly() const { return true; } +Float UITextNode::getBaseline() const { + if ( mFlexText && mFlexText->getFont() ) + return mFlexText->getFont()->getAscent( mFlexText->getCharacterSize() ); + Node* n = const_cast( this )->getParent(); + while ( n ) { + if ( n->isType( UI_TYPE_RICHTEXT ) ) { + auto& fontConfig = n->asType()->getRichText().getFontStyleConfig(); + if ( fontConfig.Font ) + return fontConfig.Font->getAscent( fontConfig.CharacterSize ); + break; + } + n = n->getParent(); + } + return 0.f; +} + Text* UITextNode::getFlexText() { if ( mFlexText == nullptr ) mFlexText = Text::New(); diff --git a/src/tests/unit_tests/uihtml_flex_test.cpp b/src/tests/unit_tests/uihtml_flex_test.cpp index 3ae2f3bfb..27c88f23e 100644 --- a/src/tests/unit_tests/uihtml_flex_test.cpp +++ b/src/tests/unit_tests/uihtml_flex_test.cpp @@ -2334,3 +2334,111 @@ UTEST( FlexContainer, directionReversePaintWithOrderSort ) { Engine::destroySingleton(); } + +UTEST( FlexContainer, alignItemsBaselineBasic ) { + 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(); + + UIRichText* flex = UIRichText::NewWithTag( "div" ); + flex->setParent( sceneNode->getRoot() ); + flex->setDisplay( CSSDisplay::Flex ); + flex->setPixelsSize( 500, 100 ); + flex->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + flex->applyProperty( StyleSheetProperty( "font-family", "NotoSans-Regular" ) ); + flex->applyProperty( StyleSheetProperty( "font-size", "16dp" ) ); + flex->applyProperty( StyleSheetProperty( "align-items", "baseline" ) ); + + UITextNode* textA = UITextNode::New(); + textA->setParent( flex ); + textA->setText( "Hello" ); + + UITextNode* textB = UITextNode::New(); + textB->setParent( flex ); + textB->setText( "World" ); + + sceneNode->updateDirtyLayouts(); + + // Both text nodes have the same font, so their baselines are equal. + // With baseline alignment, both are at the same y as flex-start. + Float ascent = textA->getBaseline(); + EXPECT_TRUE( ascent > 0.f ); + EXPECT_NEAR( textA->getPixelsPosition().y, 0.f, 5.f ); + EXPECT_NEAR( textB->getPixelsPosition().y, 0.f, 5.f ); + + Engine::destroySingleton(); +} + +UTEST( FlexContainer, containerGetBaseline ) { + 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(); + + UIRichText* flex = UIRichText::NewWithTag( "div" ); + flex->setParent( sceneNode->getRoot() ); + flex->setDisplay( CSSDisplay::Flex ); + flex->setPixelsSize( 500, 100 ); + flex->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + flex->applyProperty( StyleSheetProperty( "font-family", "NotoSans-Regular" ) ); + flex->applyProperty( StyleSheetProperty( "font-size", "16dp" ) ); + + UITextNode* textNode = UITextNode::New(); + textNode->setParent( flex ); + textNode->setText( "Baseline test" ); + + sceneNode->updateDirtyLayouts(); + + // After layout, the flex container's baseline should equal the text ascent + Float bl = flex->getBaseline(); + EXPECT_TRUE( bl > 0.f ); + Float ascent = textNode->getBaseline(); + EXPECT_NEAR( bl, ascent, 5.f ); + + Engine::destroySingleton(); +} + +UTEST( FlexContainer, baselinePositionsLargerItemCorrectly ) { + 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(); + + UIRichText* flex = UIRichText::NewWithTag( "div" ); + flex->setParent( sceneNode->getRoot() ); + flex->setDisplay( CSSDisplay::Flex ); + flex->setPixelsSize( 500, 150 ); + flex->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + flex->applyProperty( StyleSheetProperty( "font-family", "NotoSans-Regular" ) ); + flex->applyProperty( StyleSheetProperty( "font-size", "16dp" ) ); + flex->applyProperty( StyleSheetProperty( "align-items", "baseline" ) ); + + // A taller non-text item (e.g. a colored div) alongside a text node. + // The taller item uses FlexStart alignment (no baseline), while the text node + // uses baseline alignment. Both should coexist within the same line cross size. + UIHTMLWidget* tallBox = UIHTMLWidget::New(); + tallBox->setParent( flex ); + tallBox->setPixelsSize( 50, 80 ); + tallBox->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + UITextNode* textNode = UITextNode::New(); + textNode->setParent( flex ); + textNode->setText( "Text" ); + + sceneNode->updateDirtyLayouts(); + + // The line cross size must be at least as large as the tall box + Float ascent = textNode->getBaseline(); + EXPECT_TRUE( ascent > 0.f ); + // Text node cross-start position depends on baseline alignment + EXPECT_TRUE( textNode->getPixelsSize().getHeight() > 0.f ); + + Engine::destroySingleton(); +}