diff --git a/.agent/plans/flexbox_support_plan.md b/.agent/plans/flexbox_support_plan.md index 3ae701273..5b395e5be 100644 --- a/.agent/plans/flexbox_support_plan.md +++ b/.agent/plans/flexbox_support_plan.md @@ -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 `` or `
` 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 `` or `
` 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 `` or `
` 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 `` or `
` 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 ``) 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 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. diff --git a/include/eepp/ui/uitextnode.hpp b/include/eepp/ui/uitextnode.hpp index 1a99f4943..fd49855bc 100644 --- a/include/eepp/ui/uitextnode.hpp +++ b/include/eepp/ui/uitextnode.hpp @@ -1,6 +1,7 @@ #ifndef EE_UI_UITEXTNODE_HPP #define EE_UI_UITEXTNODE_HPP +#include #include 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(); }; diff --git a/src/eepp/ui/flexlayouter.cpp b/src/eepp/ui/flexlayouter.cpp index e82a99843..8ef3daaa1 100644 --- a/src/eepp/ui/flexlayouter.cpp +++ b/src/eepp/ui/flexlayouter.cpp @@ -330,14 +330,11 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis item.minMainSize = item.widget->asType()->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()->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(); + 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]; diff --git a/src/eepp/ui/uitextnode.cpp b/src/eepp/ui/uitextnode.cpp index 79fec28ff..d46733757 100644 --- a/src/eepp/ui/uitextnode.cpp +++ b/src/eepp/ui/uitextnode.cpp @@ -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()->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()->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()->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(); } } } diff --git a/src/tests/unit_tests/uihtml_flex_test.cpp b/src/tests/unit_tests/uihtml_flex_test.cpp index da8c0fc08..34723adfa 100644 --- a/src/tests/unit_tests/uihtml_flex_test.cpp +++ b/src/tests/unit_tests/uihtml_flex_test.cpp @@ -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(); +}