Add anonymous flex item wrap support.

This commit is contained in:
Martín Lucas Golini
2026-05-30 02:52:23 -03:00
parent 8be05aabdd
commit abfd10bee4
5 changed files with 252 additions and 36 deletions
+151 -12
View File
@@ -1080,14 +1080,35 @@ adjusting the stacking context.
**Impact:** Only visual; items in a column-reverse container may not paint in the
expected order when they overlap.
### G12: Anonymous flex items from bare text nodes (§4) — P3
### G12: Anonymous flex items from bare text nodes (§4) — P1
**Status:** Not implemented. Text sequences between flex item elements should wrap
in anonymous block flex items. Our `collectFlexItems()` skips `UITextNode` whitespace
but does not create anonymous wrappers for non-whitespace text.
**Status:** Partially implemented. Text node children of flex containers are now
collected as flex items, sized with single-line text width (`resolveFlexBasis`),
and rendered at their flex-assigned position (`UITextNode::draw()`). However,
multi-line word wrapping is not yet supported — text that overflows the allocated
main size is clipped/truncated rather than wrapped.
**Impact:** Bare text as direct children of flex containers doesn't participate in
flex layout. Workaround: wrap text in `<span>` or `<div>` elements.
**Impact:** Simple single-line text in flex containers works correctly. Multi-line
text or text that needs wrapping within a constrained flex item does not wrap.
Workaround: wrap multi-line text in `<span>` or `<div>` elements.
### G13: Anonymous flex item text wrapping (§4, §9.9) — P1
**Status:** Implemented. When a `UITextNode` flex item is allocated a main
size smaller than its natural text width, the text wraps to multiple lines
and the cross size grows to accommodate the wrapped height. Uses a cached
`Graphics::Text` instance on `UITextNode` with `setLineWrapMode(Word)` and
`setMaxWrapWidth()` configured during `resolveCrossSizes()`. The `Text` object
is also used in `UITextNode::draw()` for multi-line rendering.
**Implementation approach:** Option C from the investigation — `Text` instance
with `setMaxWrapWidth()` (see anchor below for full details).
**Changes:**
- `uitextnode.hpp` — Added `Graphics::Text mFlexText` value member, `getFlexText()` public accessor
- `uitextnode.cpp` — `draw()` uses `mFlexText` with `setMaxWrapWidth()` when parent is flex
- `flexlayouter.cpp` — `resolveCrossSizes()` configures `mFlexText` with font, text, `LineWrapMode::Word`, `setMaxWrapWidth(targetMainSize)`, reads `getTextHeight()` for cross size
- `flexlayouter.cpp` — `measureFlexItems()` sets `minMainSize = 0` for text nodes so `flex-shrink` can reduce width below full text length, enabling wrapping
### Summary of Gaps by Priority
@@ -1104,7 +1125,8 @@ flex layout. Workaround: wrap text in `<span>` or `<div>` elements.
| 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 |
| G12 | Anonymous flex items (§4) — single-line + wrapping | **P1** | Large | ✅ Done |
| G13 | Anonymous flex item text wrapping (§4, §9.9) | **P1** | Large | ✅ Done |
### Remaining Phase 12: Route `CSSDisplay::Flex` to FlexLayouter
@@ -1145,6 +1167,8 @@ gaps documented above. Here's the updated path forward:
- **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.
- **G12** — Anonymous flex items from bare text nodes. Text node children of flex containers are now collected as flex items. `resolveFlexBasis()` measures text content width for `flex-basis: auto`. `measureFlexItems()` sets cross size (font height) and `minMainSize`. `resolveCrossSizes()` configures wrapped Text measurement and sets cross size to wrapped height. `UITextNode::draw()` renders at the flex-assigned position.
- **G13** — Anonymous flex item text wrapping. Uses a cached `Graphics::Text` value member on `UITextNode` with `setLineWrapMode(Word)` and `setMaxWrapWidth()`. Configured in `resolveCrossSizes()`; rendered in `draw()`. `minMainSize` set to 0 for text nodes to allow flex-shrink below full text width.
### Next (fill remaining spec gaps)
- **G3** — `visibility: collapse`. Full spec feature, large effort but important for dynamic UIs.
@@ -1154,7 +1178,6 @@ gaps documented above. Here's the updated path forward:
- **G11** — Column-reverse stacking context.
- **G6** — Percentage margins/paddings.
- **G7** — Painting order by `order`.
- **G12** — Anonymous flex items.
### Final gate
- **Route `display: flex` to FlexLayouter** — Implement blockification changes
@@ -1163,10 +1186,7 @@ gaps documented above. Here's the updated path forward:
## Limitations Documented For This Implementation
1. **Anonymous flex items from bare text nodes:** Not generated. Text nodes and non-widget
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 `<span>` or `<div>` to participate in flex layout.
1. **Column-direction text wrapping in anonymous flex items:** Multi-line wrapping in `flex-direction: column` containers uses the tentative cross-axis width from `measureFlexItems()` rather than the final stretched width from `alignCrossAxis()`. In practice this means the wrapping width may not match the final allocated width for stretch-aligned column items.
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).
@@ -1543,3 +1563,122 @@ the correct behavior. No special handling needed.
| `src/eepp/ui/blocklayouter.cpp` | Modified | Skip `isInline()` early-return for flex items |
| `include/eepp/ui/flexlayouter.hpp` | **New** | FlexLayouter class with FlexItem, FlexLine, full API |
| `src/eepp/ui/flexlayouter.cpp` | **New** | Complete flex layout algorithm (~650 lines) |
| `src/eepp/ui/uitextnode.cpp` | Modified | `draw()` — single-line rendering for anonymous flex items |
| `src/eepp/ui/flexlayouter.cpp` | Modified | `resolveFlexBasis`, `measureFlexItems` — text node sizing |
---
### Anchor: Text wrapping for anonymous flex items
**Goal:** When a `UITextNode` flex item is sized to a width smaller than its
natural text width, the text should wrap, and the cross size should grow to
accommodate the wrapped height.
**Current state:** Single-line only. `UITextNode::draw()` calls static
`Text::draw()` which renders one line. Cross size = font height.
#### How the existing layout pipeline handles text wrapping
When a `UITextSpan` (inline `<span>`) becomes a flex item, `UILayouterManager::create()`
returns a `BlockLayouter` for it (not an `InlineLayouter`). That `BlockLayouter`:
1. Creates/rebuilds a `RichText` via `rebuildRichText()` with the span's content
2. `RichText::setMaxWidth()` is set to the resolved flex main size (set by `FlexLayouter`)
3. `RichText::setLineWrap(true)` enables word wrapping
4. `RichText::getSize()` returns the wrapped extent (used by `BlockLayouter` for cross size)
5. `UITextSpan::draw()` calls `getRichText().draw()` at the span's screen position
This pipeline produces multi-line wrapped text with correct cross sizing.
#### Option A: Create anonymous `UIRichText` wrapper widgets (recommended)
Create an invisible `UIRichText` wrapper for each text-only child of a flex
container during `collectFlexItems()`. The wrapper:
- Gets the text and font from the `UITextNode`
- Is inserted as a child of the flex container (or as a sibling before the text node)
- Becomes the flex item handled by the flex algorithm
- Gets `BlockLayouter` → `rebuildRichText()` → `RichText` pipeline for wrapping
- The original `UITextNode` is hidden or children-reparented into the wrapper
**Pros:** Uses the existing, proven RichText wrapping pipeline verbatim. No new
text layout code. `UITextSpan` path already proves this works.
**Cons:** Requires inserting widgets into the tree during layout, which is
architecturally problematic (tree mutation during layout). The wrapper needs to
be positioned/sized correctly. The `UITextNode` must be hidden or removed.
#### Option B: Give `UITextNode` its own `RichText` member
Add a `RichText` member to `UITextNode` (lazily allocated). In the flex sizing
pass, populate the `RichText`, set max width to the allocated main size, and
measure the wrapped height for cross sizing. In `draw()`, render via `RichText::draw()`.
**Pros:** No widget tree mutation. Self-contained in `UITextNode`.
**Cons:** Duplicates RichText infrastructure on `UITextNode`. RichText is designed
for the widget tree pipeline (owns children for inline boxes). The `UITextNode`
is not a `UIWidget` and doesn't participate in the widget tree layout normally.
Requires manual management of the RichText's text/font/width state across
layout passes.
#### Option C: Use `Graphics::Text` instance with `setMaxWrapWidth()`
In the flex sizing pass, create (or reuse a cached) `Text` object, set the font,
text, and max wrap width. Measure `Text::getTextHeight()` for cross sizing. In
`draw()`, use `Text::draw()` (instance method, not static) at the node's screen
position.
**Pros:** Simple API. `Text` already supports `setMaxWrapWidth()` and multi-line
draw. No RichText dependency. Self-contained.
**Cons:** `Text::draw()` at different Y positions for each wrapped line. Need to
verify `Text` instance correctly draws all wrapped lines at the given position
(not just the first). Less feature-rich than RichText (no inline box support,
no `text-decoration`, etc.)
#### Recommendation: Option C — `Text` instance with `setMaxWrapWidth()`
Option C is the best fit for `UITextNode` because:
1. **No widget tree mutation** — avoids the architectural problem of Option A
2. **No RichText duplication** — `Text` is a lightweight existing abstraction,
not a heavy widget-based pipeline
3. **Simple cross-size measurement** — `Text::getTextHeight()` with max width set
returns the wrapped height directly
4. **Simple rendering** — `Text::draw()` at node screen position renders all
wrapped lines
5. **Performance** — Cache the `Text` object on `UITextNode`, only recreate when
text/font/size changes. No per-frame allocation.
**Implementation sketch:**
```
// In UITextNode.h — add member:
// std::unique_ptr<Graphics::Text> mFlexText;
// In FlexLayouter — after final flexible length resolution:
// if (item.textNode) {
// auto* txt = item.textNode;
// txt->getOrCreateFlexText();
// txt->mFlexText->setStyle(txt->getFontStyle(container));
// txt->mFlexText->setString(txt->getText());
// txt->mFlexText->setMaxWrapWidth(item.mainSize - padding);
// item.crossSize = txt->mFlexText->getTextHeight() + padding;
// }
// In UITextNode::draw() when parent is flex:
// if (mFlexText) {
// mFlexText->setMaxWrapWidth(getPixelsSize().getWidth());
// mFlexText->draw(getPixelsPosition().x, getPixelsPosition().y);
// }
```
**Verification:** Existing unit tests should continue to pass (single-line text
is a subset of multi-line — a single-line `Text` with no wrap is identical to
static `Text::draw()`). A new test fixture with a constrained flex item
containing long text will verify wrapping behavior.
**Risk:** If `Text::draw()` (instance) does not correctly draw at the given
position for multi-line wrapped text, fall back to Option B. Verify this
before committing the implementation.
+4
View File
@@ -1,6 +1,7 @@
#ifndef EE_UI_UITEXTNODE_HPP
#define EE_UI_UITEXTNODE_HPP
#include <eepp/graphics/text.hpp>
#include <eepp/ui/uiwidget.hpp>
namespace EE { namespace UI {
@@ -32,9 +33,12 @@ class EE_API UITextNode : public UIWidget {
void setLayoutCharCount( size_t count ) { mLayoutCharCount = count; }
Graphics::Text& getFlexText() { return mFlexText; }
protected:
String mText;
size_t mLayoutCharCount{ 0 };
Graphics::Text mFlexText;
UITextNode();
};
+41 -8
View File
@@ -330,14 +330,11 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis
item.minMainSize =
item.widget->asType<UIHTMLWidget>()->getLayouter()->getMinIntrinsicWidth();
else if ( item.widget->isType( UI_TYPE_TEXTNODE ) ) {
// Text node min size = measured text width (content-based minimum)
FontStyleConfig fontConfig;
if ( getFontStyleFromAncestor( mContainer, fontConfig ) ) {
item.minMainSize = Text::getTextWidth(
item.widget->asType<UITextNode>()->getText(), fontConfig );
} else {
item.minMainSize = item.widget->getPixelsSize().getWidth();
}
// Text node with wrapping: minimum size = 0, since text can wrap
// to any width and the cross size grows to accommodate the content.
// Without wrapping, the minimum would be the full text width,
// which prevents any flex-shrink.
item.minMainSize = 0.f;
} else
item.minMainSize = item.widget->getPixelsSize().getWidth();
if ( item.minMainSize < 0.f )
@@ -724,6 +721,42 @@ void FlexLayouter::resolveCrossSizes( FlexLine& line, const Axis& crossAxis,
}
}
// For text nodes (anonymous flex items), configure a cached Text object for
// multi-line word wrapping. In row direction, measure wrapped text height at
// the resolved main size and use it as the cross size.
for ( size_t idx : line.itemIndices ) {
auto& item = mItems[idx];
if ( item.widget->isType( UI_TYPE_TEXTNODE ) ) {
auto* textNode = item.widget->asType<UITextNode>();
if ( !textNode->getText().empty() ) {
FontStyleConfig fontConfig;
if ( getFontStyleFromAncestor( mContainer, fontConfig ) ) {
Graphics::Text& flexText = textNode->getFlexText();
flexText.setStyleConfig( fontConfig );
flexText.setString( textNode->getText() );
flexText.setLineWrapMode( LineWrapMode::Word );
Float maxWidth;
if ( mainAxis.horizontal ) {
maxWidth = item.targetMainSize;
} else {
// Column: wrap at tentative cross-axis width
maxWidth = item.widget->getPixelsSize().getWidth();
}
if ( maxWidth <= 0.f )
maxWidth = 10000.f;
flexText.setMaxWrapWidth( maxWidth );
if ( mainAxis.horizontal ) {
Float wrappedHeight = flexText.getTextHeight();
if ( wrappedHeight <= 0.f )
wrappedHeight =
(Float)fontConfig.Font->getFontHeight( fontConfig.CharacterSize );
item.widget->setInternalPixelsHeight( wrappedHeight );
}
}
}
}
}
Float maxCross = 0.f;
for ( size_t idx : line.itemIndices ) {
auto& item = mItems[idx];
+21 -16
View File
@@ -29,28 +29,33 @@ void UITextNode::draw() {
if ( mText.empty() )
return;
// In flex layout, text nodes are anonymous flex items. The parent flex
// container does not render their text via RichText (it was cleared),
// so the text node must draw itself.
Node* parent = getParent();
bool isFlexItem =
parent && parent->isType( UI_TYPE_HTML_WIDGET ) && parent->asType<UIHTMLWidget>()->isFlex();
if ( isFlexItem ) {
// Walk up parent chain to find a UIRichText ancestor with font info
Node* n = parent;
while ( n ) {
if ( n->isType( UI_TYPE_RICHTEXT ) ) {
auto& fontConfig = n->asType<UIRichText>()->getRichText().getFontStyleConfig();
if ( fontConfig.Font ) {
FontStyleConfig fc = fontConfig;
Float alpha = getAlpha();
if ( alpha < 1.f )
fc.FontColor.a = (Uint8)( (Float)fc.FontColor.a * alpha );
Text::draw( mText, mScreenPos.trunc(), fc );
if ( mFlexText.getFont() ) {
mFlexText.setMaxWrapWidth( getPixelsSize().getWidth() );
Float alpha = getAlpha();
if ( alpha < 1.f )
mFlexText.setAlpha( (Uint8)( 255.f * alpha ) );
mFlexText.draw( mScreenPos.x, mScreenPos.y );
} else {
// Fallback: Text not yet configured by flex layouter — draw single-line
Node* n = parent;
while ( n ) {
if ( n->isType( UI_TYPE_RICHTEXT ) ) {
auto& fontConfig = n->asType<UIRichText>()->getRichText().getFontStyleConfig();
if ( fontConfig.Font ) {
FontStyleConfig fc = fontConfig;
Float alpha = getAlpha();
if ( alpha < 1.f )
fc.FontColor.a = (Uint8)( (Float)fc.FontColor.a * alpha );
Text::draw( mText, mScreenPos.trunc(), fc );
}
break;
}
break;
n = n->getParent();
}
n = n->getParent();
}
}
}
+35
View File
@@ -1588,3 +1588,38 @@ UTEST( FlexContainer, anonymousTextNodeWithFixedSibling ) {
Engine::destroySingleton();
}
UTEST( FlexContainer, anonymousTextNodeWrapping ) {
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( 100, 200 );
flex->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
flex->applyProperty( StyleSheetProperty( "font-family", "NotoSans-Regular" ) );
flex->applyProperty( StyleSheetProperty( "font-size", "16dp" ) );
flex->applyProperty( StyleSheetProperty( "align-items", "flex-start" ) );
// Long text that should wrap within the 100px-wide flex container
UITextNode* textNode = UITextNode::New();
textNode->setParent( flex );
textNode->setText( "This is a very long text that should wrap to multiple lines inside the narrow flex container." );
sceneNode->updateDirtyLayouts();
// The font height for NotoSans at 16dp is ~22px. If the text wraps, the total
// height should be at least 2x the single-line font height.
FontStyleConfig fontConfig = flex->getRichText().getFontStyleConfig();
Float fontHeight = (Float)fontConfig.Font->getFontHeight( fontConfig.CharacterSize );
EXPECT_TRUE( textNode->getPixelsSize().getWidth() > 0.f );
EXPECT_TRUE( textNode->getPixelsSize().getHeight() >= fontHeight * 2.f );
EXPECT_TRUE( textNode->getPixelsSize().getWidth() <= 100.f );
Engine::destroySingleton();
}