Flex container baseline computation.

This commit is contained in:
Martín Lucas Golini
2026-05-31 02:38:19 -03:00
parent 37fcf45595
commit 9fa16d6d70
8 changed files with 260 additions and 20 deletions

View File

@@ -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

View File

@@ -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<FlexItem, 16> mItems;
Float mContainerBaseline{ 0.f };
CSSFlexDirection mDirection{ CSSFlexDirection::Row };
CSSFlexWrap mWrap{ CSSFlexWrap::NoWrap };
CSSJustifyContent mJustify{ CSSJustifyContent::FlexStart };

View File

@@ -160,6 +160,8 @@ class EE_API UIHTMLWidget : public UILayout {
virtual void drawChildren();
Float getBaseline() const;
virtual void onParentChange();
virtual void onPositionChange();

View File

@@ -35,6 +35,8 @@ class EE_API UITextNode : public UIWidget {
Text* getFlexText();
Float getBaseline() const;
protected:
String mText;
size_t mLayoutCharCount{ 0 };

View File

@@ -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<UIHTMLWidget>()->getBaseline();
if ( widget->isType( UI_TYPE_TEXTNODE ) )
return widget->asType<UITextNode>()->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<FlexLine, 8>& lines,
@@ -992,6 +1025,27 @@ void FlexLayouter::alignCrossAxis( const SmallVector<FlexLine, 8>& 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<FlexLine, 8>& 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 ) {

View File

@@ -1,6 +1,7 @@
#include <algorithm>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/css/stylesheetlength.hpp>
#include <eepp/ui/flexlayouter.hpp>
#include <eepp/ui/uihtmlwidget.hpp>
#include <eepp/ui/uilayouter.hpp>
#include <eepp/ui/uilayoutermanager.hpp>
@@ -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<FlexLayouter*>( 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;

View File

@@ -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<UITextNode*>( this )->getParent();
while ( n ) {
if ( n->isType( UI_TYPE_RICHTEXT ) ) {
auto& fontConfig = n->asType<UIRichText>()->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();

View File

@@ -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();
}