Huge RichText refactor to improve inline-box support. Still WIP.

This commit is contained in:
Martín Lucas Golini
2026-05-21 12:22:49 -03:00
parent dd2134b0d0
commit c834f82566
21 changed files with 3942 additions and 4817 deletions

View File

@@ -1,286 +0,0 @@
# Border Box Model Content Offset Plan
## Problem Statement
In browser engines, content (text, child widgets) is positioned at `border-width + padding` from the element's edge. In eepp's GUI system, borders are `BorderType::Inside` by default — a pure visual decoration drawn OVER the padding/content area. Content is only offset by padding, ignoring border width entirely. This causes HTML widgets to render text misaligned compared to real browsers.
**User requirement:** UIHTMLWidget elements must behave like browsers — the border should consume space and content should be offset by border + padding.
---
## Key Concepts
| Concept | eepp Current | Browser/CSS | Desired |
|---------|-------------|-------------|---------|
| Border space | 0 (Inside type) | border-width | border-width |
| Content offset | padding only | border + padding | border + padding |
| `box-sizing` | not implemented | `content-box` (default) | add `content-box`/`border-box` |
**BorderType behavior:**
- `Inside` (default): border drawn inside element; `getBorderBoxDiff()` returns zero rect; no space consumed
- `Outside`: border extends outward from element; adds space outside
- `Outline`: border centered on element edge; half inside, half outside
For HTML compatibility, we treat borders as **space-consuming** regardless of BorderType — they push content inward by their width. This matches the CSS `content-box` model where `width` specifies the content area.
---
## Scope: What Changes
### 1. Add helper method to UINode/UIWidget
**File:** `include/eepp/ui/uiwidget.hpp` (declaration), `src/eepp/ui/uiwidget.cpp` (implementation)
Add `getPixelsContentOffset()` that returns a `Rectf` containing `padding + border` for all 4 sides. This becomes the single source of truth for content positioning in HTML widgets.
```cpp
// Returns the content area origin offset = padding + border
Rectf getPixelsContentOffset() const;
```
Implementation:
```cpp
Rectf UIWidget::getPixelsContentOffset() const {
Rectf offset = getPixelsPadding();
if (hasBorder()) {
const auto& b = getBorder()->getBorders();
offset.Left += b.left.width;
offset.Right += b.right.width;
offset.Top += b.top.width;
offset.Bottom += b.bottom.width;
}
return offset;
}
```
**Complexity: LOW** — one new method, ~15 lines.
---
### 2. BlockLayouter — Content Area Calculations
**File:** `src/eepp/ui/blocklayouter.cpp`
All locations that use `mContainer->getPixelsPadding()` for child positioning must switch to `mContainer->getPixelsContentOffset()`.
**Affected lines (~8 sites):**
| Line(s) | Current | Change |
|---------|---------|--------|
| 34-43 `computeIntrinsicWidths` | `getPixelsPadding().Left + .Right` | add border widths to intrinsic size |
| 74-77 `updateLayout` totW | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` |
| 88-92 `updateLayout` totH | `getPixelsPadding().Top + .Bottom` | `getPixelsContentOffset().Top + .Bottom` |
| 161-167 `positionRichTextChildren` hitbox | `getPixelsPadding().Left/Top` | `getPixelsContentOffset().Left/Top` |
| 214-216 BR element width | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` |
| 227-228 custom widget position | `getPixelsPadding().Left/Top` | `getPixelsContentOffset().Left/Top` |
**Complexity: MEDIUM** — mechanical replacement, ~8 call sites.
---
### 3. UIRichText — Text Rendering & Intrinsic Widths
**File:** `src/eepp/ui/uirichtext.cpp`
| Line(s) | Current | Change |
|---------|---------|--------|
| 180-186 `draw()` | `mScreenPos + mPaddingPx.Left/Top` | add border width to offset |
| 589-590 `rebuildRichText` maxWidth | `- getPixelsPadding().Left - .Right` | `- getPixelsContentOffset().Left - .Right` |
| 638-641 block child width | `- getPixelsPadding().Left - .Right` | `- getPixelsContentOffset().Left - .Right` |
| 665-668 child size computation | same pattern | same |
| 725-728 `getMinIntrinsicWidth` | `+ mPaddingPx.Left + .Right` | `+ getPixelsContentOffset().Left + .Right` |
| 753-756 `getMaxIntrinsicWidth` | same | same |
**Complexity: MEDIUM** — ~6 call sites, same mechanical pattern.
---
### 4. UIHTMLWidget — Out-of-Flow Children
**File:** `src/eepp/ui/uihtmlwidget.cpp`
| Line | Current | Change |
|------|---------|--------|
| 202 `positionOutOfFlowChildren` | `getPixelsPadding().Left, .Top` | `getPixelsContentOffset().Left, .Top` |
Container block origin for absolutely positioned children must include border.
**Complexity: LOW** — single line change.
---
### 5. TableLayouter — Table Layout
**File:** `src/eepp/ui/tablelayouter.cpp`
| Line(s) | Current | Change |
|---------|---------|--------|
| 274-277 `computeIntrinsicWidths` | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` |
| 309 available width | `getPixelsPadding().Left + .Right` | `getPixelsContentOffset().Left + .Right` |
| 513, 516 row positioning | `getPixelsPadding().Left` | `getPixelsContentOffset().Left` |
| 527-529 wrap-content height | `getPixelsPadding().Top + .Bottom` | `getPixelsContentOffset().Top + .Bottom` |
**Complexity: LOW** — ~4 call sites.
---
### 6. UIWidget::getMatchParentWidth/Height
**File:** `src/eepp/ui/uiwidget.cpp` (lines ~2577-2617)
These methods calculate how much space a `match_parent` child can use. Currently subtract parent padding; must also subtract parent border.
```cpp
// Before:
Float width = getParent()->getPixelsSize().getWidth() - marginLeft - marginRight -
padding.Left - padding.Right;
// After:
Rectf parentOffset = getParent()->asType<UIWidget>()->getPixelsContentOffset();
Float width = getParent()->getPixelsSize().getWidth() - marginLeft - marginRight -
parentOffset.Left - parentOffset.Right;
```
**Complexity: LOW** — 2 methods, ~4 subtraction lines each.
---
### 7. UIWidget::calculateAutoMargin
**File:** `src/eepp/ui/uiwidget.cpp` (lines ~590-659)
`margin: auto` calculation uses parent padding to determine available space. Must include parent border.
```cpp
// Before:
Float availableWidth = parentSize.getWidth() - parentPadding.Left - parentPadding.Right -
getPixelsSize().getWidth();
// After:
Rectf parentContentOffset = ...getPixelsContentOffset();
Float availableWidth = parentSize.getWidth() - parentContentOffset.Left -
parentContentOffset.Right - getPixelsSize().getWidth();
```
**Complexity: LOW** — ~4 call sites.
---
### 8. (Optional) CSS `box-sizing` Property
**Scope:** Can be deferred. Adding it now would make the fix more complete but doubles the complexity.
If implemented:
- Add `BoxSizing` to `PropertyId` enum (`propertydefinition.hpp`)
- Register property: `registerProperty("box-sizing", "content-box")`
- Under `content-box`: width/height set on content area; border+padding added outside (current plan)
- Under `border-box`: width/height include border+padding; content = width - padding - border (would need reverse calculation)
**Complexity: HIGH** — new property, two calculation modes, affects all width/height resolution. Recommended as follow-up.
---
## Non-Scope / NOT Changing
- **Non-HTML widgets** (UIPushButton, UITextInput, etc.) — they continue using `getPixelsPadding()` directly and border remains decorative.
- **UINode::nodeDraw()` clip regions** — the clipping pipeline already uses `getBorderBoxDiff()` for BorderBox clip; no change needed.
- **UIBorderDrawable rendering** — border geometry generation is unchanged.
- **BorderType behavior** — Inside/Outside/Outline remain as-is; we only USE the border width value for content offset, regardless of type.
- **Background rendering** — backgrounds already render within the padded area; we're only moving content inward.
---
## Test Impact & Validation Protocol
### Expected Test Failures
This change alters the content area origin for all HTML widgets — text, child widgets, intrinsic widths, and match-parent sizing all shift. This means:
**Guaranteed to fail:**
- `UIBorder.renderingVariations` — text inside bordered boxes will shift inward by the border width, changing pixel positions. **This failure IS the expected correct behavior** (the test proves the fix works).
- `UIRichText.anchorMargins` — content offset changes affect the rendered layout.
- `UIRichText.spanPadding` — spans with padding inside bordered containers shift.
- `UIHTMLTable.complexLayout` (1,2,3) — any elements with borders will have their text/content shifted.
**Expected to pass unchanged:**
- Non-HTML widget tests (UILayout, FontRendering, etc.) — these widgets don't use the HTML border model.
- Tests where no element has a border — no content offset change occurs.
**Unknown (may or may not differ):**
- Margin-dependent tests (e.g., `UILayout.marginAuto`) — if parent has a border, `getMatchParentWidth/Height` result changes.
- Layout tests with nested containers — cascading size changes from border inclusion could alter layouts.
### What "Re-generate and Verify" Means
The `compareImages` helper in the unit tests works as follows:
1. **Golden image check:** On each test run, the rendered frame is captured via `win->getFrontBufferImage()` and pixel-compared against a stored `.webp` image at `bin/unit_tests/assets/<folder>/<imageName>.webp`.
2. **Auto-generation on first run:** If the golden image file does not exist, the captured frame is saved AS the new golden image and the test passes. This is how `eepp-ui-border-rendering.webp` was created.
3. **Re-generation for updated rendering:** To update a golden image after an intentional rendering change:
```bash
# Delete the old golden image, re-run the test to auto-create a new one
rm bin/unit_tests/assets/html/eepp-ui-table-complex.webp
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" \
bin/unit_tests/eepp-unit_tests-debug --filter="UIHTMLTable.complexLayout"
```
4. **Human validation is REQUIRED after re-generation.** The test will pass automatically once the golden image is regenerated, but this proves nothing — it only proves the rendering is consistent with itself. A human must visually inspect the new rendering (against the old golden image, or against a reference browser rendering) to confirm the change is correct and not a regression. The agent can assist by:
- Describing expected visual differences (e.g., "all text should be shifted right by 4px in bordered elements")
- Comparing pixel dimensions between old and new golden images
- Rendering the same HTML in a reference browser for side-by-side comparison (if the agent has image analysis capabilities)
### Agent Protocol for Failing Tests
When tests fail due to expected rendering changes, the agent MUST:
1. **Report** which tests failed and whether the failure is expected (border-related shift) or unexpected (regression).
2. **Do NOT auto-regenerate** golden images without first describing the expected visual differences to the user.
3. **Request human validation** by explaining what changed and asking the user to confirm the new rendering looks correct. Example: *"The UIBorder.renderingVariations test failed because text inside bordered boxes shifted right by border-left-width and down by border-top-width. I'll regenerate the golden image now — please visually verify the result matches expectations."*
4. **Regenerate golden images only after approval** — delete the old `.webp`, re-run the test, and confirm it passes.
5. **Verify with a reference browser** if the agent has image analysis capabilities — render the same HTML in a browser and compare.
---
## Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| Breaking non-HTML widgets | HIGH | Helper method on UIWidget, but only HTML layouters (BlockLayouter, UIRichText, TableLayouter) call it. Non-HTML widgets keep using `getPixelsPadding()` directly. |
| Intrinsic width changes breaking layout | MEDIUM | Run existing HTML layout image tests after each change. Verify pixel-identical rendering with re-generated golden images. |
| Match-parent calculations | MEDIUM | `getMatchParentWidth/Height` is called by ALL widgets, not just HTML. Must gate the border addition on whether parent has a border. |
| Circle dependency on border resolution | LOW | `updateBorders()` is lazy — widths are empty strings until first draw. We must call `mOwner->lengthFromValue(...)` to resolve before reading. In the `getPixelsContentOffset()` method, `getBorder()->getBorders()` accesses already-resolved values — `updateBorders()` is called in `UIBorderDrawable::update()` before draw. |
---
## Implementation Order
1. **Add `getPixelsContentOffset()` method** to UIWidget (declaration + implementation)
2. **Update BlockLayouter** — switch all `getPixelsPadding()` to `getPixelsContentOffset()`
3. **Update UIRichText** — text rendering offset and intrinsic widths
4. **Update UIHTMLWidget** — out-of-flow children offset
5. **Update TableLayouter** — table cell padding offset
6. **Update `getMatchParentWidth/Height`** — gate on parent having border, subtract parent border
7. **Update `calculateAutoMargin`** — gate on parent having border, subtract parent border
8. **Run all tests** — identify which fail and classify as expected vs unexpected
9. **Request human validation** — for all tests with expected failures, describe the visual change and ask for confirmation
10. **Regenerate golden images after approval** — delete old `.webp` files, re-run tests to capture new baseline
11. **Verify non-HTML widgets unaffected** — ensure non-HTML tests still pass with existing golden images
---
## Verification
After implementation, the agent must:
1. **Run the full test suite** and compile a failure report categorizing each as:
- **Expected failure (border shift):** tests where content moved due to border offset — these visually differ from old golden images
- **Unexpected failure:** tests where the change caused a regression — these must be investigated and fixed
- **Passing unchanged:** tests that continue to match their existing golden images
2. **For each expected failure**, describe to the user exactly what changed (e.g., "text in `UIRichText.anchorMargins` shifted right by the container's border-left-width of 4px"). See [Test Impact & Validation Protocol](#test-impact--validation-protocol).
3. **Await human approval** before regenerating any golden images.
4. **After approval**, regenerate golden images for the affected tests and confirm they pass.
5. **Verify** the `UIBorder.renderingVariations` test now produces the correct browser-like rendering (text inside bordered boxes is properly offset by border + padding).

View File

@@ -1,126 +0,0 @@
# CSS Block Semantics — Full Compliance Plan
This plan addresses the remaining pragmatic deviations between the RichText-based layout engine and the CSS 2.1 block formatting context model. The goal is a fully compliant web engine with no special-case "richtext mode" vs. "HTML mode" distinction.
**AGENT DIRECTIVE (CRITICAL):** Compile and run the unit tests after EVERY step. Take a git stash snapshot (`git stash push -m "Step X.Y passed"`) upon passing a step. Do NOT proceed if any test regresses.
---
## Current Deviation Summary
| # | Deviation | Impact |
|---|-----------|--------|
| D1 | `isBlock && flowX > 0` allows blocks to sit beside floats when no inline content preceded them. CSS spec: block always starts on a new line (§9.4.1). | Blocks float-side-by-side in float-aware path instead of stacking vertically. |
| D2 | RichText `CustomBlock` with `isBlock` piggybacks line-break behavior onto a text-flow engine. A block formatting context should stack child boxes vertically, not inline them with `isBlock` flags. | Semantic confusion: the RichText engine conflates text flow and block layout. |
| D3 | Inline-block spans are decomposed into RichText text segments that wrap line-by-line, rather than being treated as a single opaque box. | The "solid box" semantics of inline-block are partially emulated with `atomicMaxX` / forced line breaks, but edge cases remain. |
| D4 | `<richtext>` widgets and `<div>` containers are both `UIRichText` with `CSSDisplay::Block`, yet `<richtext>` has historically flowed children inline. The tag-based distinction was removed; now both use CSS display semantics, which is correct. | No remaining deviation — this is resolved in the current PR. |
## Phase 1: Isolate RichText from Block Layout
The RichText engine (`Graphics::RichText`) should handle **text formatting only** — word wrapping, line breaking, glyph placement. It should NOT decide whether an element breaks to a new line. That decision belongs to the Block Formatting Context established by the `BlockLayouter`.
### Step 1.1: Remove `isBlock` from `RichText::CustomBlock` and `RichText::addCustomSize`
- `CustomBlock` drops the `isBlock` field.
- `addCustomSize` drops the `isBlock` parameter.
- `RichText::updateLayout()` removes all `isBlock`-gated line-break logic (both non-float and float-aware paths). Every `CustomBlock` flows as an inline-level atomic box.
- All `fillParent` / width-override logic stays in `UIRichText::rebuildRichText`.
- **Validation:** Compile. Many tests will fail — this is expected. Continue.
### Step 1.2: Move block-level line breaking into `BlockLayouter`
- `BlockLayouter::updateLayout()` now builds the RichText content in **per-child layers** rather than delegating everything to a single `rebuildRichText` call.
- For each child widget:
- If the child is mergeable (text span, inline), it is added to the current RichText via `UIRichText::rebuildRichText`.
- If the child is a block-level element (display != Inline and != InlineBlock), a **new line** is forced before the child. This is done by inserting a `\n` into the RichText or by finalizing the current line and starting a fresh `RenderParagraph`.
- The container's own `positionRichTextChildren` is called once after ALL children are placed.
- **Validation:** All tests pass. (Snapshot)
### Step 1.3: Remove the `flowX` workaround
- With block-line-breaking moved into `BlockLayouter`, the `flowX` / `curX` saving logic in the float-aware path is no longer needed. Blocks always start on a new line (per CSS).
- Remove the `flowX` variable and associated logic from `RichText::updateLayout()` float-aware path.
- Remove the "block overflow below floats" block (it's superseded by the block-layouter approach).
- **Validation:** `UIHTML.blockFlow`, `UIHTML.blockFlowFloat`, all `UIHTMLFloat.*` tests pass. (Snapshot)
## Phase 2: Full Block Formatting Context
### Step 2.1: Block children interact with floats
- When `BlockLayouter` places a block child and there are active floats (from the RichText engine at the current Y), the block child's margin box must respect the float constraints:
- If the block fits in the available width (between left-floats and right-floats), it stays on the same line but its width is narrowed.
- If the block does NOT fit, it moves to the next "float-free" Y (below all active floats) — effectively an implicit clear.
- Implement this in `BlockLayouter::updateLayout()` by querying `RichText` for the current float state.
- The existing float overflow logic in `RichText` (for float-on-float overflow) stays in place. The block-vs-float overflow is now handled in `BlockLayouter`.
- **Validation:** `UIHTMLFloat.floatWrapsContentBelowWhenTooWide` and all float tests pass. (Snapshot)
### Step 2.2: `positionRichTextChildren` handles block-level Y offsets
- Currently, block and inline children are positioned by the same `positionRichTextChildren` loop, which walks RichText lines and assigns positions.
- With the new approach, block children are placed at the start of a new RichText line (their own line). Their Y position is determined by the line they occupy.
- `BlockLayouter` may need to track which children are block vs. inline so it can query the correct line Y.
- **Validation:** All block-positioning tests pass (`UITextNode_BlockLayouter.*`, `UIHTML.blockFlow`, `UIRichText.MarginsTest`). (Snapshot)
## Phase 3: Inline-Block Box Semantics
### Step 3.1: Treat inline-block as an opaque `CustomBlock`
- In `UIRichText::rebuildRichText`, when a child span has `isInlineBlock() == true`, do NOT flatten its text into the parent RichText.
- Instead, render the inline-block's content into its OWN `RichText` instance (via its own `BlockLayouter`), producing a single `Sizef` representing the box.
- Add this box to the parent RichText via `addCustomSize` (with `floatType = None`, `clearType = None`). No `isBlock` flag needed.
- This makes the inline-block a single opaque rectangle in the parent's inline flow — exactly matching CSS.
- Remove the `atomicMaxX` tracking and multiline forced-break logic from `RichText::updateLayout()` (both paths).
- **Validation:** `UIHTML.InlineBlockBrowserTest`, `UIHTML.InlineBlock*` tests pass. (Snapshot)
### Step 3.2: Inline-block height and baseline alignment
- CSS inline-blocks align to the parent's baseline. The `RichText` line layout must handle the inline-block's height correctly (via `maxAscent` and `lineHeight` in `RenderParagraph`).
- If the inline-block contains text, its own RichText produces a height. This height must be passed to the parent's `addCustomSize` as the box height.
- Baseline of the inline-block = baseline of its last line of text (or bottom if no text).
- **Validation:** Inline-block vertical alignment tests pass. (Snapshot)
## Phase 4: Cleanup and Regression
### Step 4.1: Remove dead code
- Remove `isBlock`-related fields from `SpanBlock` and `CustomBlock` structs.
- Remove `isBlock` parameter from `RichText::addSpan` (line-height variant), `RichText::addCustomSize`.
- Remove `atomicMaxX` tracking from both layout paths.
- Remove `flowX` logic from the float-aware path.
- Remove the "block overflow below floats" special case from the float-aware path.
- **Validation:** Full test suite (280 tests). Must all pass. (Snapshot)
### Step 4.2: Add spec-compliance regression tests
- Write tests for edge cases identified during migration:
- Block after float with no inline content: block must start on a new line (D1 resolution).
- Block with explicit width after float: block starts below float if it doesn't fit.
- Float → Block → Inline sequence: block goes below float, inline flows beside block.
- Inline-block beside float: inline-block box flows beside float, not decomposed.
- Nested inline-blocks: outer inline-block contains inner inline-block.
- **Validation:** All new tests pass. (Snapshot)
---
## Migration Order (Dependency Graph)
```
Phase 1 (isolate RichText)
├─ 1.1 Remove isBlock from RichText
├─ 1.2 Move line breaking to BlockLayouter
└─ 1.3 Remove flowX workaround
Phase 2 (block formatting context)
├─ 2.1 Block-float interaction in BlockLayouter
└─ 2.2 positionRichTextChildren block Y offsets
Phase 3 (inline-block box semantics)
├─ 3.1 Inline-block as opaque CustomBlock
└─ 3.2 Baseline alignment
Phase 4 (cleanup)
├─ 4.1 Remove dead code
└─ 4.2 Spec-compliance regression tests
```
Each phase is a self-contained checkpoint. No phase should leave the test suite in a broken state.

View File

@@ -1,233 +0,0 @@
# CSS Inline Baseline Alignment Plan
## Goal
Add spec-aligned support for CSS inline baseline alignment now that `RichText::CustomBlock` can carry real baselines.
Initial scope:
- `vertical-align` for inline-level boxes and table cells where already practical.
- `alignment-baseline` as an inline SVG/CSS alignment property that can share the same inline baseline machinery.
- Shared internal representation for baseline alignment so text spans, inline-blocks, images/custom blocks, and future SVG text can use the same layout path.
This must be implemented as generic inline formatting behavior, not as element-specific fixes.
## Specification References
Primary references:
- CSS 2.2 `vertical-align`: https://www.w3.org/TR/CSS22/visudet.html#propdef-vertical-align
- CSS 2.2 inline formatting model: https://www.w3.org/TR/CSS22/visuren.html#inline-formatting
- CSS Inline Layout Level 3 alignment terms: https://www.w3.org/TR/css-inline-3/
- SVG 2 `alignment-baseline`: https://www.w3.org/TR/SVG2/text.html#AlignmentBaselineProperty
Important spec notes:
- `vertical-align` applies to inline-level boxes and table cells, and is not inherited.
- Percentage values for `vertical-align` refer to the element's own `line-height`.
- Keyword values adjust a box relative to the parent line box baseline, text metrics, or line box.
- `alignment-baseline` is inherited in SVG contexts and should eventually apply to SVG text/layout. For eepp's HTML compatibility layer, it can initially map to the same baseline-alignment value type used by inline formatting.
## Current State
Relevant implementation points:
- `RichText::SpanBlock` stores text, margin, padding, and optional line-height.
- `RichText::CustomBlock` stores size, float/clear, and baseline.
- `RichText::updateLayout()` computes `line.maxAscent`, then assigns `span.position.y` from the baseline.
- Text spans currently use `line.maxAscent - textBlock->getCharacterSize()`, which is only an approximation of baseline alignment.
- Custom blocks use `line.maxAscent - custom.baseline`, which is the right extension point for inline-blocks and replaced content.
- `UIRichText::rebuildRichText()` already computes custom-block baselines from internal RichText lines.
- There is no implemented `vertical-align` property. Existing `row-valign` / `row-vertical-align` is unrelated table row UI behavior and should not be reused as CSS `vertical-align`.
## Design
### 1. Add A Shared Alignment Type
Add a CSS-facing value type, likely in `csslayouttypes.hpp`, such as:
```cpp
enum class CSSBaselineAlignment {
Baseline,
Sub,
Super,
TextTop,
TextBottom,
Middle,
Top,
Bottom,
Length,
Percentage,
Auto
};
```
Also store the numeric offset for `<length>` and `<percentage>` values. A small struct is preferable to overloading the enum:
```cpp
struct CSSBaselineAlignValue {
CSSBaselineAlignment type{ CSSBaselineAlignment::Baseline };
std::string specified;
Float value{ 0.f };
bool percentage{ false };
};
```
The actual names can be adjusted to match existing eepp style.
### 2. Register CSS Properties
In `StyleSheetSpecification::registerDefaultProperties()`:
- Register `vertical-align` with initial value `baseline`, not inherited.
- Register `alignment-baseline` with initial value `baseline` or `auto` according to the chosen supported subset. Mark it inherited only if implementation targets the SVG/SVG-text behavior from the spec.
Do not alias `vertical-align` to existing `row-valign`; that would conflate separate CSS concepts.
### 3. Store The Computed Values On HTML Widgets
Add baseline alignment storage at the lowest common inline-participation layer:
- `UIHTMLWidget` should store the parsed value for boxes that become `CustomBlock`s.
- `UITextSpan` / `UIRichText` should expose it for text spans and nested inline content.
- Replaced elements such as `UIHTMLImage` inherit this through `UIHTMLWidget` once they are represented as custom blocks.
Implementation options:
- Add fields and accessors to `UIHTMLWidget`, and let `UIRichText`/`UITextSpan` use the same inherited logic.
- Or add the property to `UIWidget` only if non-HTML UI widgets need baseline alignment in RichText. Prefer `UIHTMLWidget` for now.
Property application:
- `vertical-align` accepts keywords, lengths, and percentages.
- `alignment-baseline` accepts a keyword subset initially: `baseline`, `middle`, `text-before-edge`/`text-top`, `text-after-edge`/`text-bottom`, `central`, `before-edge`, `after-edge`, `hanging`, `mathematical`, and `auto` as feasible.
- Unsupported keywords should parse to a known fallback only if the spec allows it; otherwise leave the property unapplied.
### 4. Extend RichText Blocks With Alignment Metadata
Extend `RichText::SpanBlock` and `RichText::CustomBlock` with baseline alignment metadata:
```cpp
CSSBaselineAlignValue baselineAlign;
```
Update:
- `RichText::addSpan(...)`
- `RichText::addCustomSize(...)`
- all call sites in `UIRichText::rebuildRichText()`
- line-break custom block construction
Default value must preserve current behavior: baseline alignment.
### 5. Compute Text Baselines From Font Metrics
Replace the current text offset approximation:
```cpp
line.maxAscent - textBlock->getCharacterSize()
```
with real font metrics:
- ascent from `Font::getAscent(characterSize)`,
- descent/line spacing if available,
- used line height from `SpanBlock::lineHeight` or normal font line spacing.
The text baseline for a text span should be its ascent from the top of its own inline box. This aligns with the existing custom-block model, where baseline is an offset from the top of the box.
### 6. Apply Baseline Alignment During Line Layout
Add a helper in `RichText`, conceptually:
```cpp
Float resolveBaselineOffset(
const RenderParagraph& line,
const RenderSpan& span,
Float naturalBaseline,
const CSSBaselineAlignValue& align,
const FontStyleConfig* parentFontStyle );
```
Supported `vertical-align` behavior:
- `baseline`: no extra shift.
- `<length>`: raise the box by positive length and lower by negative length.
- `<percentage>`: same as length, resolved against the element's own line-height.
- `sub` / `super`: use font-derived or conservative spec-compatible offsets. Prefer a font metric if available; otherwise use a documented fraction of font size.
- `text-top`: align the top of the box with the parent content area's top.
- `text-bottom`: align the bottom of the box with the parent content area's bottom.
- `middle`: align the midpoint of the box with the parent baseline plus half x-height. If x-height is unavailable, use a documented fallback based on font size.
- `top`: align the top of the box with the top of the line box.
- `bottom`: align the bottom of the box with the bottom of the line box.
Implementation may need two passes:
1. Compute natural baselines, ascent/descent extents, and provisional line box metrics.
2. Apply vertical-align shifts and expand line height if shifted boxes extend outside current line bounds.
This must be done in both RichText paths:
- fast path without floats,
- float-aware path.
Float placement must remain edge-aligned and should ignore inline baseline alignment, as documented in `html-layout-architecture.md`.
### 7. Map `alignment-baseline`
For the first implementation:
- If `vertical-align` is specified, it controls CSS inline alignment for HTML inline-level boxes.
- If `alignment-baseline` is specified and `vertical-align` is not specified, map supported keywords to the shared baseline alignment value for inline/SVG contexts.
- Keep unsupported SVG-specific values documented as TODOs.
Do not pretend full SVG text layout is complete if only inline block alignment is wired.
## Implementation Steps
1. Add property IDs/specification entries for `vertical-align` and `alignment-baseline`.
2. Add parser/helper functions for baseline alignment values.
3. Store computed alignment value on `UIHTMLWidget` / `UIRichText` / `UITextSpan`.
4. Extend `RichText::SpanBlock` and `RichText::CustomBlock` to carry alignment metadata.
5. Update `UIRichText::rebuildRichText()` to pass each node's computed alignment into `addSpan()` / `addCustomSize()`.
6. Replace text baseline approximation with real font ascent-based baseline.
7. Implement alignment resolution in `RichText::updateLayout()` fast path.
8. Mirror the same alignment resolution in the float-aware path.
9. Add tests.
10. Update `.agent/rules/html-layout-architecture.md` if implementation details differ from this plan.
## Tests
Add narrow unit tests in `richtext_tests.cpp`:
- Text span with `vertical-align: baseline` preserves current baseline placement.
- Inline custom block with `vertical-align: middle` is vertically centered relative to surrounding text.
- Inline custom block with `vertical-align: text-top` and `text-bottom`.
- Inline custom block with positive and negative length values.
- Inline custom block with percentage values based on its own line-height.
- Mixed text sizes where font ascent, not character size, controls baseline.
Add HTML/UI tests in `uihtml_tests.cpp`:
- `img { vertical-align: middle; }` in a text line.
- `details.caches { display: inline-block; vertical-align: baseline; }` remains unchanged from the Lobsters regression.
- `details.caches { vertical-align: middle; }` moves only by baseline alignment, not by changing element height.
- `alignment-baseline: middle` maps to the same behavior where supported.
Regression guard:
- Existing `UIHTMLDetails.*`, `UILayout.listStyle*`, and `UIRichText.MinMaxWidthChildren` must remain green.
## Risks
- `vertical-align: top` and `bottom` require line-box-level alignment and may need a clean second pass to avoid circular line-height changes.
- Text metrics may differ by font backend. Tests should use tolerances and the existing unit-test font.
- SVG-specific `alignment-baseline` keywords are more nuanced than HTML inline `vertical-align`; document any unsupported values clearly.
- Existing tests may encode the old character-size baseline approximation. Prefer updating them to assert spec behavior rather than preserving the approximation.
## Non-Goals For First Pass
- Full SVG text layout.
- CSS Box Alignment properties unrelated to inline baselines.
- Table-cell `vertical-align` beyond mapping to existing table-cell placement if the table layouter does not already expose the needed hooks.
- Browser-perfect x-height if the active font API cannot expose it yet.

View File

@@ -1,264 +0,0 @@
# First-Class Inline Boxes Plan
## Goal
Replace the current RichText flattening approximation with a real inline formatting tree where inline boxes are modeled as first-class layout objects.
This is needed to correctly support nested inline CSS behavior such as:
- `vertical-align` on an inline parent containing child text or anchors.
- Inline margins, padding, borders, and backgrounds across nested content.
- Accurate line box ascent/descent aggregation.
- Correct inline hit boxes and child widget positioning after line wrapping.
The implementation must preserve the current good behavior from the baseline alignment work and avoid element-specific fixes.
## Specification References
Primary references:
- CSS 2.2 inline formatting model: https://www.w3.org/TR/CSS22/visuren.html#inline-formatting
- CSS 2.2 line height and vertical-align: https://www.w3.org/TR/CSS22/visudet.html#line-height
- CSS 2.2 inline non-replaced box dimensions: https://www.w3.org/TR/CSS22/visudet.html#inline-non-replaced
- CSS Display Level 3: https://www.w3.org/TR/css-display-3/
- CSS Inline Layout Level 3: https://www.w3.org/TR/css-inline-3/
Important spec constraints:
- `vertical-align` applies to inline-level and table-cell boxes, but is not inherited.
- Inline boxes can contain text runs, other inline boxes, and atomic inline-level boxes.
- Inline-blocks are atomic in the parent inline formatting context and expose a baseline according to CSS rules.
- Line boxes are built from the inline boxes that participate in each line, including parent inline box metrics, not only leaf text runs.
## Current State
The current pipeline is:
1. `UIRichText::rebuildRichText()` recursively walks children.
2. Text nodes and inline spans are flattened into `RichText::SpanBlock`.
3. Inline-blocks and replaced content become `RichText::CustomBlock`.
4. `RichText::updateLayout()` lays out a flat list of spans/custom blocks.
5. `BlockLayouter::positionRichTextChildren()` maps render spans back to widgets.
This works for many cases, but loses the identity of parent inline boxes. The recent `getEffectiveInlineBaselineAlign()` workaround preserves the nearest explicit inline alignment when flattening nested inline text, but it is still an approximation.
Known limitations caused by flattening:
- Parent inline `vertical-align` can be lost when visible text is inside a child inline element.
- Inline parent padding/borders/backgrounds cannot be split correctly across line wraps.
- Nested inline hit boxes and visual boxes are reconstructed after the fact instead of being laid out directly.
- Future support for inline border painting, decoration propagation, and richer line-height behavior will be fragile.
## Target Model
Introduce an inline formatting model with explicit inline layout items.
Conceptual node types:
```cpp
struct InlineTextRun {
String text;
FontStyleConfig style;
UITextNode* sourceNode;
};
struct InlineBox {
UIWidget* sourceWidget;
CSSBaselineAlignValue baselineAlign;
Rectf margin;
Rectf padding;
BorderMetrics border;
std::vector<InlineItem> children;
};
struct AtomicInlineBox {
UIWidget* sourceWidget;
Sizef marginBoxSize;
Float baseline;
CSSBaselineAlignValue baselineAlign;
CSSFloat floatType;
CSSClear clearType;
};
```
Exact names and storage should follow eepp style, but the key requirement is that inline parent boxes survive until line layout.
## Architecture
### 1. Build An Inline Tree
Replace or augment `UIRichText::rebuildRichText()` with a builder that emits an inline tree:
- Text nodes become text runs.
- True inline `UITextSpan` / `UIHTMLWidget` nodes become `InlineBox` nodes.
- Inline-blocks, images, controls, list items, and other atomic inline-level content become `AtomicInlineBox`.
- Out-of-flow descendants remain skipped and positioned later.
- Floats remain represented as atomic boxes with float metadata.
Whitespace collapsing should continue to happen in the builder, but it must operate across nested inline boundaries.
### 2. Shape And Split Text Runs
Line wrapping must be able to split text runs while preserving ancestor inline boxes.
Required behavior:
- A text run can produce multiple line fragments.
- Each line fragment carries the active ancestor inline box chain.
- Ancestor inline boxes generate per-line fragments when their content wraps.
- Leading/trailing inline margins/padding/borders apply according to whether the fragment is the first or last fragment of that inline box.
Initial implementation can support margins/padding first and leave border/background painting as a follow-up, as long as the fragment model is ready for it.
### 3. Compute Inline Metrics
Each inline item should expose:
- advance width,
- content box height,
- used line height,
- natural baseline,
- ascent/descent extents relative to its own baseline,
- `vertical-align` shift.
Text runs use font metrics:
- ascent,
- descent,
- line spacing,
- x-height fallback for `middle`.
Atomic inline boxes use:
- provided widget size plus margin,
- internal baseline for inline-blocks when available,
- bottom edge fallback according to CSS.
Inline parent boxes use the union of child fragments and their own padding/border/margin contribution.
### 4. Build Line Boxes From Fragments
The line builder should produce `InlineLine` objects containing fragment boxes, not just leaf render spans.
Each line should know:
- line y,
- line height,
- baseline,
- max ascent/descent,
- fragment list,
- width.
`vertical-align: top` and `bottom` need line-box-level resolution. The algorithm may require:
1. Build provisional line with baseline-aligned items.
2. Resolve line height.
3. Apply top/bottom aligned items.
4. Recompute final extents if needed.
This should replace duplicated logic in both RichText paths. The float-aware path can still provide available width and float placement, but inline vertical alignment should be shared.
### 5. Preserve Widget Mapping
After layout, map generated inline fragments back to source nodes:
- Text nodes receive one or more hit boxes.
- Inline widgets receive one or more fragment boxes.
- Inline-block/replaced widgets receive one atomic fragment.
- Parent inline boxes spanning multiple lines receive multiple fragment boxes.
`BlockLayouter::positionRichTextChildren()` should consume this structured result instead of inferring child positions from flattened spans.
### 6. Drawing And Hit Testing
Drawing should eventually use the same fragment list:
- Text runs draw at fragment positions.
- Inline parent backgrounds/borders draw per fragment.
- Decorations can be applied consistently over fragmented inline boxes.
- Hit testing uses fragment boxes directly.
Initial migration can keep existing drawing for text while using fragment boxes for widget placement, but the final target should remove duplicated reconstruction logic.
## Migration Strategy
### Phase 1: Non-Behavioral Data Model
- Add internal inline item/fragment structs behind `RichText`.
- Keep existing flat `SpanBlock` / `CustomBlock` APIs working.
- Add debug-only or test-only accessors for generated fragments.
- Do not change rendering behavior yet.
### Phase 2: Build Inline Tree From HTML
- Add a new builder path in `UIRichText::rebuildRichText()`.
- Emit nested inline boxes for true inline widgets.
- Keep a compatibility path if needed while tests are ported.
- Preserve whitespace collapsing behavior exactly.
### Phase 3: Shared Line Layout
- Implement line construction from inline items.
- Reuse existing wrapping decisions where possible.
- Keep float handling outside the inline algorithm, but feed available line widths into it.
- Ensure fast path and float-aware path share vertical alignment code.
### Phase 4: Widget Position Mapping
- Update `BlockLayouter::positionRichTextChildren()` to use inline fragments.
- Support multi-fragment inline widgets.
- Preserve existing positions for atomic inline-blocks, images, list markers, details/summary, and form controls.
### Phase 5: Remove Flattening Workarounds
- Remove `getEffectiveInlineBaselineAlign()` once parent inline boxes directly participate in line layout.
- Remove any code that copies parent inline alignment into descendant flattened spans.
- Keep `vertical-align` non-inherited.
### Phase 6: Painting Improvements
- Use inline fragments for inline backgrounds, borders, and decoration painting.
- Add support for split inline backgrounds/borders across wrapped lines.
- Review selection rendering and hit boxes against the new fragments.
## Tests
Add narrow unit tests for the inline layout engine:
- Nested inline parent with `vertical-align: bottom` and child anchor text aligns the parent box bottom to the line bottom.
- Child inline element does not inherit `vertical-align` in computed style.
- Nested inline `vertical-align: middle` does not affect sibling baselines incorrectly.
- Inline parent with text before and after child creates one coherent inline box.
- Inline parent split across two wrapped lines produces two fragments.
- Inline-block baseline still follows CSS bottom-edge fallback when it has no in-flow line boxes.
- Inline-block with in-flow internal line boxes exposes its last line baseline.
Add UIHTML fixture tests:
- `reddit_header.html`: `.hover.pagename.redditname` bottom-aligns in `#header-bottom-left`.
- `inline_block.html`: footer/share-button heights remain close to browser behavior.
- Existing `reddit_header_icons.html` remains unchanged.
- `UIHTMLDetails.*`, `UIRichText.MinMaxWidthChildren`, and `UILayout.listStyle*` remain green.
Add RichText tests:
- Multi-line nested inline box fragments preserve source widget hit boxes.
- `vertical-align: top` and `bottom` resolve after final line height is known.
- Length and percentage vertical-align values work for nested inline boxes.
## Risks
- Whitespace collapsing can regress at inline boundaries.
- Text selection and hit testing may shift if fragment ownership changes.
- Float-aware layout can diverge from the fast path if shared inline code is not factored carefully.
- Multi-line inline parent fragments can expose existing assumptions that each widget has one rectangle.
- Golden image diffs are expected; prioritize numeric layout invariants and real browser comparisons before updating goldens.
## Completion Criteria
- Nested inline CSS alignment works without copying inherited values to descendants.
- `vertical-align` remains non-inherited in computed style.
- Parent inline boxes have fragment boxes and can affect line metrics directly.
- Existing baseline alignment workaround is removed.
- Current HTML regression tests pass, except intentionally updated goldens.
- New tests cover nested inline boxes, fragmented inline boxes, and realistic reddit header behavior.

View File

@@ -1,813 +0,0 @@
# HTML Background Properties — Analysis & Implementation Plan
## 1. HTML CSS Background Property Specification (Full Set)
The CSS Backgrounds and Borders Module Level 3 defines these background properties:
| # | Property | Values | Default | Layered |
|---|----------|--------|---------|---------|
| 1 | `background-color` | `<color>` | `transparent` | No |
| 2 | `background-image` | `none \| <image> [, <image>]*` | `none` | Yes |
| 3 | `background-position` | `<position> [, <position>]*` | `0% 0%` | Yes |
| 4 | `background-size` | `auto \| cover \| contain \| <length-percentage>{1,2}` | `auto` | Yes |
| 5 | `background-repeat` | `<repeat-style> [, <repeat-style>]*` | `repeat` | Yes |
| 6 | `background-origin` | `border-box \| padding-box \| content-box` | `padding-box` | Yes |
| 7 | `background-clip` | `border-box \| padding-box \| content-box` | `border-box` | Yes |
| 8 | `background-attachment` | `scroll \| fixed \| local` | `scroll` | Yes |
| 9 | `background` (shorthand) | `<bg-layer> [, <bg-layer>]* <final-bg-layer>` | — | Yes |
**Repeat-style values:** `repeat-x | repeat-y | [repeat | space | round | no-repeat]{1,2}`
**Position syntax:** `<position> = [left | center | right | <length-percentage>] || [top | center | bottom | <length-percentage>] | [left | center | right | <length-percentage>] [top | center | bottom | <length-percentage>] | [center | [left | right] <length-percentage>?] && [center | [top | bottom] <length-percentage>?]`
**Shorthand syntax:** `[ <bg-layer> , ]* <final-bg-layer>` where each `<bg-layer> = <bg-image> || <bg-position> [ / <bg-size> ]? || <repeat-style> || <attachment> || <box> || <box>` and `<final-bg-layer> = <'background-color'> || <bg-image> || <bg-position> [ / <bg-size> ]? || <repeat-style> || <attachment> || <box> || <box>`
---
## 2. eepp Current Background Implementation
### 2.1 Property Coverage
| HTML Property | eepp Status | eepp Mapping |
|---------------|-------------|--------------|
| `background-color` | **Fully supported** | `PropertyId::BackgroundColor``UIBackgroundDrawable::setColor()` |
| `background-image` | **Fully supported** | `PropertyId::BackgroundImage` (indexed) → `LayerDrawable::setDrawable()` |
| `background-position` | **Partially supported** | `PropertyId::BackgroundPositionX/Y` (indexed, split axes) |
| `background-size` | **Partially supported** | `PropertyId::BackgroundSize` (indexed) |
| `background-repeat` | **Partially supported** | `PropertyId::BackgroundRepeat` (indexed, single keyword only) |
| `background-origin` | **NOT IMPLEMENTED** | — |
| `background-clip` | **NOT IMPLEMENTED** | — |
| `background-attachment` | **NOT IMPLEMENTED** | — |
| `background` (shorthand) | **Partially supported** | Missing `size`, origin, clip, attachment, comma-layers |
### 2.2 Architecture
```
UINode::drawBackground()
└── UINodeDrawable::draw(position, size, alpha)
├── [1] mBackgroundColor.draw() — solid fill (UIBackgroundDrawable)
├── [2] Stencil mask (if border-radius + layers)
└── [3] For each LayerDrawable (reverse order):
└── LayerDrawable::draw() — image/gradient with repeat, position, size
```
Components:
- **`UIBackgroundDrawable`** (`uinodedrawable(uibackgrounddrawable.hpp/cpp`): solid-color fill with border-radius support. Extends `Graphics::Drawable`.
- **`UINodeDrawable`** (`uinodedrawable.hpp/cpp`): container holding one `UIBackgroundDrawable` (background color) plus a `std::map<int, LayerDrawable*>` (background image layers).
- **`LayerDrawable`** (inner class of `UINodeDrawable`): represents one background image layer. Stores position (x/y strings), size equation, repeat mode, and the drawable itself.
Repeat modes (`UINodeDrawable::Repeat`):
```cpp
enum Repeat { RepeatXY, RepeatX, RepeatY, NoRepeat };
```
Currently mapped from CSS:
- `repeat``RepeatXY`
- `repeat-x``RepeatX`
- `repeat-y``RepeatY`
- `no-repeat``NoRepeat`
`space` and `round` are **not** implemented.
---
## 3. Detailed Differences: eepp vs HTML
### 3.1 Property: `background-position` (Highest Priority)
| Aspect | HTML CSS Spec | eepp Current |
|--------|--------------|--------------|
| **Reference box** | `background-origin` controls it (default `padding-box`) | Always widget content area (`getPixelsSize()`) |
| **Percentage formula** | `(ref_box_size - image_size) × percentage` | Same formula — CORRECT |
| **Keyword mapping** | `left`=0%, `center`=50%, `right`=100%, `top`=0%, `bottom`=100% | Same mapping — CORRECT |
| **4-value syntax** | `right 10px top 20px` = 10px from right, 20px from top | Supported via split into posX/posY with 2 tokens each |
| **3-value syntax** | `right 10px top` (axis-swap handling) | Axis-swap in shorthand parser — CORRECT |
| **Axis determination** | First vertical keyword triggers axis swap | Shorthand parser detects `isYAxis(c1) \|\| isXAxis(c2)` — CORRECT |
| **Multi-layer comma-separated** | `10px 20px, 50% 50%` | Supported via comma-split in shorthand parser |
| **Percentage rounding** | Not specified; browsers round to sub-pixel | eepp rounds when input ends with `%` — difference in sub-pixel precision |
**Image atlas use case (the primary motivation):** `background-position` combined with `background-size` and a fixed container size is the standard web technique to render sprites/image atlases. For example:
```css
.icon {
width: 32px; height: 32px;
background-image: url(atlas.png);
background-size: 256px 256px; /* atlas dimensions */
background-position: -64px -96px; /* offset into the atlas */
background-repeat: no-repeat;
}
```
This renders a 32×32 region from the atlas at coordinates (64, 96). This works because:
1. `background-size` scales the image to the specified atlas dimensions
2. `background-position` offsets which part of the scaled image is visible
3. The widget size acts as a viewport/crop window
eepp's implementation already supports this pattern — the math is identical. However, verification and testing of this exact workflow is the top priority.
**Behavioral difference:** In HTML, `background-position: 0 0` places the image at the top-left of the **padding box**. In eepp, it places it at the top-left of the **content area** (widget rect). When borders and padding are non-zero, the HTML image is shifted inward by `border + padding`.
**The real difference** comes from:
1. **Borders**: HTML default `background-origin: padding-box` means image position ignores border width. In eepp, border is an overlay (Inside type) — background is unaffected.
2. **background-origin/content-box**: When origin is `content-box`, HTML uses content area as reference. eepp can't express this distinction.
### 3.2 Property: `background-repeat`
| Aspect | HTML CSS | eepp |
|--------|----------|------|
| **`repeat`** | Tile both axes | `RepeatXY` — CORRECT |
| **`repeat-x`** | Tile horizontally, no-repeat vertically | `RepeatX` — CORRECT |
| **`repeat-y`** | Tile vertically, no-repeat horizontally | `RepeatY` — CORRECT |
| **`no-repeat`** | No tiling | `NoRepeat` — CORRECT |
| **`space`** | Tile, space evenly, no clipping | **NOT IMPLEMENTED** |
| **`round`** | Tile, scale to fit whole number, no clipping | **NOT IMPLEMENTED** |
| **Two-value syntax** | `repeat no-repeat` = X:repeat, Y:no-repeat | **NOT IMPLEMENTED** — only single keyword |
### 3.3 Property: `background-size`
| Aspect | HTML CSS | eepp |
|--------|----------|------|
| **`auto`** | Natural size | Uses `mDrawable->getPixelsSize()` or `mSize` for rectangles — MATCHES |
| **`cover`** | Scale to cover, preserve ratio, may clip | `eemax(scale1, scale2)` — CORRECT |
| **`contain`** | Scale to fit, preserve ratio, no clipping | `eemin(scale1, scale2)`, only scales down — **DIFFERENCE**: HTML `contain` may scale UP if image is smaller than container; eepp only scales DOWN when `Scale1 < 1 \|\| Scale2 < 1`. |
| **Explicit `100px auto`** | Fixed width, proportional height | Supported — CORRECT |
| **Percentage values** | `50% 100%` relative to positioning area | Supported — CORRECT |
### 3.4 Property: `background-color`
Fully supported. No differences. eepp also has `background-tint` as an extension (no HTML equivalent for per-layer tint).
### 3.5 Property: `background-image`
Fully supported including multiple layers, gradients (`linear-gradient`), icons, textures. No HTML-visible differences.
### 3.6 Property: `background-origin` (NOT IMPLEMENTED)
This controls the **positioning reference box** for `background-position`:
| Value | Meaning |
|-------|---------|
| `border-box` | Position relative to outer border edge |
| `padding-box` (default) | Position relative to inner border edge (padding area) |
| `content-box` | Position relative to content area |
In eepp, background position is always relative to the widget's content area (which equals the padding area since padding is inside mSize, but the border reference is impossible).
**Impact:** When `background-origin: content-box` is specified in HTML and the element has padding, the image shifts inward by the padding amount compared to the default (`padding-box`). eepp cannot express this.
### 3.7 Property: `background-clip` (NOT IMPLEMENTED)
This controls the **painting/clipping area** for backgrounds:
| Value | Meaning |
|-------|---------|
| `border-box` (default) | Background paints to border outer edge |
| `padding-box` | Background paints to border inner edge (padding area) |
| `content-box` | Background paints only to content area |
In eepp, `mClipEnabled` controls a clip plane. When set (triggered by any layer having repeat != NoRepeat), it clips to the widget rect (the padding area). When not set, there's no clipping — the background can extend beyond the widget. There's no distinction between border/padding/content clip regions.
**Impact:** In HTML `background-clip: content-box`, the background color/image is clipped to the content area and does NOT appear under padding. eepp always paints the background under the padding area.
### 3.8 Property: `background-attachment` (NOT IMPLEMENTED)
| Value | Meaning |
|-------|---------|
| `scroll` (default) | Background scrolls with the element's containing block |
| `fixed` | Background fixed relative to the viewport |
| `local` | Background scrolls with the element's content (scrollable containers) |
In eepp, all backgrounds behave as if `scroll` — they're positioned relative to the widget and move with it. There's no viewport-relative or content-relative background positioning.
### 3.9 Property: `background` Shorthand
HTML full syntax supports:
```
background: [<bg-image> || <bg-position>[/<bg-size>]? || <repeat-style> || <attachment> || <box> || <box>]# <bg-color>
```
eepp current shorthand:
```cpp
registerShorthand("background",
{"background-color", "background-image", "background-repeat", "background-position"},
"background");
```
Missing from eepp shorthand:
1. **`background-size`** via `/` separator — e.g., `background: url(...) center / cover`
2. **`background-origin`** and **`background-clip`** box keywords
3. **`background-attachment`** keywords
4. **Comma-separated multi-layer** — e.g., `background: url(a.png) top, url(b.png) bottom`
5. **Token mapping bug** — the parser maps repeat to `value` (all tokens), not just the repeat token (line 1031: `properties.emplace_back(StyleSheetProperty(propNames[pos], value))` should use `tok`, not `value`)
---
## 4. Implementation Plan
### 4.0 Aspirational Objective & Priority Framing
The end goal of the HTML compatibility layer is to be able to render complex real-world websites such as **reddit.com**. The single most important background feature needed for this is `background-position` — it is pervasively used on reddit for image atlases, sprite sheets, and decorative positioning. Everything else in this plan is subordinate to getting `background-position` and its companion properties (`background-size`, `background-repeat`, and the `background` shorthand) working correctly in the HTML context.
**Priority split:**
| Category | Scope |
|----------|-------|
| **Must-Have** (Phase 1) | `background-position`, `background-repeat` (incl. two-value + space/round), `background-size` (fix contain), `background` shorthand (incl. `/size`, comma-layers). Also: define and register `background-origin`, `background-clip`, `background-attachment` with full **state-passing** plumbing (CSS → `UINode``UINodeDrawable``LayerDrawable`), but without full rendering implementation. |
| **Cool-to-Have** (Phase 2) | Full rendering implementation for `background-origin`, `background-clip`, `background-attachment`. |
Phase 2 only begins after Phase 1 is fully green — all tests pass, all must-have features implemented.
### 4.1 Step Completion Protocol
For **each step** completed, the implementer must:
1. **Add tests** validating the implementation (golden image tests for rendering changes, or unit/functional tests for parser/state changes, wherever practical).
2. **Build the project** (debug mode) and verify zero compilation errors.
3. **Run the relevant test suite** and confirm all tests pass (existing + newly added).
4. **Git stash** the completed step with a descriptive message:
```bash
git stash push -m "plan: html-background phase1 step<N>: <description>"
```
This ensures we can revert to any previous stable phase at any time. Each stash represents one stable checkpoint.
**Stash naming convention:** `plan: html-background phase<1|2> step<N>: <short description>`
### 4.2 Design Principle: Background Mode
Add a `BackgroundMode` enum to `UINodeDrawable` to distinguish native eepp mode from HTML compatibility mode:
```cpp
enum class BackgroundMode { Native, Html };
```
- **Native mode** (default for existing eepp widgets): preserves current behavior exactly. No new properties take effect.
- **HTML mode** (default for `UIHTMLWidget` descendants): enables full HTML background semantics.
The mode is set during widget construction. `UIHTMLWidget` constructor sets the mode to `Html`.
### 4.3 New CSS Properties (Defined in Phase 1, Full Rendering in Phase 2)
#### `background-origin` (indexed, per-layer)
```cpp
// propertydefinition.hpp
BackgroundOrigin = String::hash("background-origin"),
```
```cpp
// stylesheetspecification.cpp
registerProperty("background-origin", "padding-box").setIndexed();
```
Values: `border-box`, `padding-box`, `content-box`. Store in `LayerDrawable` as enum.
#### `background-clip` (indexed, per-layer)
```cpp
// propertydefinition.hpp
BackgroundClip = String::hash("background-clip"),
```
```cpp
// stylesheetspecification.cpp
registerProperty("background-clip", "border-box").setIndexed();
```
Values: `border-box`, `padding-box`, `content-box`. Store in `LayerDrawable` as enum.
#### `background-attachment` (indexed, per-layer)
```cpp
// propertydefinition.hpp
BackgroundAttachment = String::hash("background-attachment"),
```
```cpp
// stylesheetspecification.cpp
registerProperty("background-attachment", "scroll").setIndexed();
```
Values: `scroll`, `fixed`, `local`. Store in `LayerDrawable` as enum.
> **Phase 1 scope:** These properties are parsed, stored, and the full state pipeline works end-to-end (CSS string → `StyleSheetProperty` → `UIWidget::applyProperty` → `UINode` setter → `UINodeDrawable` → `LayerDrawable` field). Rendering based on these values is deferred to Phase 2.
#### `background-repeat` — extend for two-value + space/round
Expand `UINodeDrawable::Repeat`:
```cpp
enum class RepeatX { NoRepeat, Repeat, Space, Round };
enum class RepeatY { NoRepeat, Repeat, Space, Round };
struct RepeatMode {
RepeatX x;
RepeatY y;
};
```
Add two-value repeat parsing: `"repeat no-repeat"` → x=repeat, y=no-repeat. `"space round"` → x=space, y=round.
---
## 5. Phase 1 — Must-Have (Implementation Steps)
### Step 1: Add `BackgroundMode` to `UINodeDrawable`
**Files:** `include/eepp/ui/uinodedrawable.hpp`, `src/eepp/ui/uinodedrawable.cpp`
```cpp
// Header
enum class BackgroundMode { Native, Html };
class EE_API UINodeDrawable : public Drawable {
// ...
void setBackgroundMode(BackgroundMode mode);
BackgroundMode getBackgroundMode() const;
protected:
BackgroundMode mBackgroundMode{BackgroundMode::Native};
};
```
**Tests:** Unit test that `UINodeDrawable` defaults to `Native`, and `set/getBackgroundMode` round-trips correctly.
**Stash:** `plan: html-background phase1 step1: add BackgroundMode enum to UINodeDrawable`
---
### Step 2: Add origin/clip/attachment enums + fields to `LayerDrawable` (state plumbing only)
**File:** `include/eepp/ui/uinodedrawable.hpp`
```cpp
class LayerDrawable : public Drawable {
// ...
enum class Origin { PaddingBox, BorderBox, ContentBox };
enum class Clip { BorderBox, PaddingBox, ContentBox };
enum class Attachment { Scroll, Fixed, Local };
static Origin originFromText(const std::string& text);
static Clip clipFromText(const std::string& text);
static Attachment attachmentFromText(const std::string& text);
void setOrigin(const std::string& origin);
void setClip(const std::string& clip);
void setAttachment(const std::string& attachment);
Origin getOrigin() const;
Clip getClip() const;
Attachment getAttachment() const;
// New members (stored, but NOT consumed by rendering in Phase 1):
std::string mOriginEq{"padding-box"};
std::string mClipEq{"border-box"};
std::string mAttachmentEq{"scroll"};
Origin mOrigin{Origin::PaddingBox};
Clip mClip{Clip::BorderBox};
Attachment mAttachment{Attachment::Scroll};
};
```
**Tests:** Round-trip test: `originFromText("content-box") == Origin::ContentBox`, etc.
**Stash:** `plan: html-background phase1 step2: add origin clip attachment enums and fields to LayerDrawable`
---
### Step 3: Register `background-origin`, `background-clip`, `background-attachment` CSS properties
**File:** `src/eepp/ui/css/stylesheetspecification.cpp`
```cpp
// In registerDefaultProperties(), add after background-size:
registerProperty("background-origin", "padding-box").setIndexed();
registerProperty("background-clip", "border-box").setIndexed();
registerProperty("background-attachment", "scroll").setIndexed();
```
**File:** `include/eepp/ui/css/propertydefinition.hpp`
```cpp
BackgroundOrigin = String::hash("background-origin"),
BackgroundClip = String::hash("background-clip"),
BackgroundAttachment = String::hash("background-attachment"),
```
**Tests:** Verify properties are registered and accessible via `StyleSheetSpecification`.
**Stash:** `plan: html-background phase1 step3: register new background CSS properties`
---
### Step 4: Dispatch new properties in `UIWidget::applyProperty()` + `getPropertyString()`
**File:** `src/eepp/ui/uiwidget.cpp`
```cpp
case PropertyId::BackgroundOrigin:
setBackgroundOrigin(attribute.value(), attribute.getIndex());
break;
case PropertyId::BackgroundClip:
setBackgroundClip(attribute.value(), attribute.getIndex());
break;
case PropertyId::BackgroundAttachment:
setBackgroundAttachment(attribute.value(), attribute.getIndex());
break;
```
Add reverse-lookup in `getPropertyString()` for all three.
**Tests:** Set properties via CSS string, read back via `getPropertyString()`, verify round-trip.
**Stash:** `plan: html-background phase1 step4: dispatch new background properties in applyProperty`
---
### Step 5: Add setters to `UINode` → `UINodeDrawable` → `LayerDrawable`
**Files:** `include/eepp/ui/uinode.hpp`, `src/eepp/ui/uinode.cpp`
```cpp
UINode* setBackgroundOrigin(const std::string& origin, int index = 0);
UINode* setBackgroundClip(const std::string& clip, int index = 0);
UINode* setBackgroundAttachment(const std::string& att, int index = 0);
```
Each delegates to `setBackgroundFillEnabled(true)->setDrawableOrigin(index, origin)` etc.
**Files:** `include/eepp/ui/uinodedrawable.hpp`, `src/eepp/ui/uinodedrawable.cpp`
```cpp
void UINodeDrawable::setDrawableOrigin(int index, const std::string& origin);
void UINodeDrawable::setDrawableClip(int index, const std::string& clip);
void UINodeDrawable::setDrawableAttachment(int index, const std::string& att);
```
Each calls `getLayer(index)->setOrigin(origin)` etc. and invalidates.
**Tests:** Set origin/clip/attachment on a widget, verify the `LayerDrawable` fields hold the correct values.
**Stash:** `plan: html-background phase1 step5: add state pipeline UINode->UINodeDrawable->LayerDrawable`
---
### Step 6: Extend `background-repeat` — two-value syntax + `space`/`round`
**File:** `include/eepp/ui/uinodedrawable.hpp`
```cpp
enum class RepeatX { NoRepeat, Repeat, Space, Round };
enum class RepeatY { NoRepeat, Repeat, Space, Round };
```
**File:** `src/eepp/ui/uinodedrawable.cpp`
Replace current `Repeat` enum usage with `RepeatMode {x, y}`. Update `repeatFromText()` to support:
- Single keyword: `"repeat"` → `{RepeatX::Repeat, RepeatY::Repeat}`
- Two-value: `"repeat no-repeat"` → `{RepeatX::Repeat, RepeatY::NoRepeat}`
- `"space"` / `"round"` keywords (Phase 1: stored as state, rendering in step 8)
**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::draw()`
Replace the `switch(mRepeat)` with independent X and Y repeat handling. Current `NoRepeat`/`RepeatX`/`RepeatY`/`RepeatXY` behavior maps to the same rendering. `Space`/`Round` are stored but not rendered yet (step 8).
**Tests:**
- `repeatFromText("repeat no-repeat")` → `{Repeat, NoRepeat}`
- `repeatFromText("space round")` → `{Space, Round}`
- Golden image test: `background-repeat: no-repeat repeat` rendered as independent axes.
**Stash:** `plan: html-background phase1 step6: two-value repeat + space/round parsing`
---
### Step 7: Fix `background-size: contain` scaling-up behavior
**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::calcDrawableSize()`
HTML `contain` scales the image **both up and down** to fit within the container while preserving aspect ratio. eepp currently only scales **down**:
```cpp
// Current (incorrect):
if ( Scale1 < 1 || Scale2 < 1 ) {
Scale1 = eemin( Scale1, Scale2 );
size = Sizef( drawableSize.getWidth() * Scale1, drawableSize.getHeight() * Scale1 );
} else {
size = drawableSize;
}
// Fixed (HTML-compatible):
Scale1 = eemin( Scale1, Scale2 );
size = Sizef( drawableSize.getWidth() * Scale1, drawableSize.getHeight() * Scale1 );
```
Always apply the minimum scale, even if it means scaling up (when both scales are > 1).
**Tests:** Golden image test with an image smaller than its container using `background-size: contain`. The image should scale UP to fill the smaller dimension.
**Stash:** `plan: html-background phase1 step7: fix background-size contain to scale up`
---
### Step 8: Implement `space` and `round` repeat rendering
**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::draw()`
- **`space`**: Calculate how many whole images fit in the container. Distribute remaining space evenly as gaps between images.
```cpp
int count = eefloor(mSize.getWidth() / mDrawableSize.getWidth());
if (count < 1) count = 1;
Float gap = (mSize.getWidth() - count * mDrawableSize.getWidth()) / (count + 1);
// Draw 'count' images, each offset by 'gap' from the previous
```
- **`round`**: Calculate how many images fit, scale so a whole number fits exactly.
```cpp
Float scale = mSize.getWidth() / mDrawableSize.getWidth();
int count = eemax(1, (int)Math::round(scale));
Float roundedWidth = mSize.getWidth() / count;
Float aspectRatio = mDrawableSize.getHeight() / mDrawableSize.getWidth();
Float roundedHeight = roundedWidth * aspectRatio;
// Draw 'count' images at the rounded size
```
Both must interact correctly with `background-position` (first tile starts from the computed offset, then tiles outward in both directions).
**Tests:**
- Golden image: `background-repeat: space` — verify even gap distribution across 3+ tiles.
- Golden image: `background-repeat: round` — verify tiles are scaled to fit exactly without gaps.
**Stash:** `plan: html-background phase1 step8: space and round repeat rendering`
---
### Step 9: Rewrite `background` shorthand parser
**File:** `src/eepp/ui/css/stylesheetspecification.cpp`
1. Expand shorthand property list:
```cpp
registerShorthand("background",
{"background-color", "background-image", "background-position", "background-size",
"background-repeat", "background-attachment", "background-origin", "background-clip"},
"background");
```
2. Rewrite shorthand parser to:
- **Split by comma** for multi-layer support
- **Detect `/` separator** for `position / size`
- Recognize `border-box`/`padding-box`/`content-box` as origin/clip (first = origin, second = clip)
- Recognize `scroll`/`fixed`/`local` as attachment
- Include `space` and `round` in repeat keyword list
- **Fix the token bug**: line 1031 uses `value` (all tokens) instead of `tok` for repeat
- Generate indexed `StyleSheetProperty` values with comma-separated indices per layer
3. The parser must produce valid output for all CSS3 `background` shorthand forms:
```
background: #f00 url(a.png) top left / 50% auto no-repeat;
background: url(a.png) center / cover, url(b.png) top left no-repeat, #ccc;
background: padding-box border-box url(a.png) fixed;
```
**Tests:**
- Parser unit test: feed shorthand strings and verify expanded properties.
- Golden image: render an element with `background: url(...) center / cover no-repeat` via shorthand.
**Stash:** `plan: html-background phase1 step9: rewrite background shorthand parser`
---
### Step 10: Enable HTML mode by default for HTML widgets
**File:** `src/eepp/ui/uihtmlwidget.cpp`
In `UIHTMLWidget` constructor or initialization, ensure the background mode is set to `Html`:
```cpp
// In constructor or first background access:
getBackground()->setBackgroundMode(UINodeDrawable::BackgroundMode::Html);
```
**Tests:** Verify that `UIHTMLWidget` instances have `BackgroundMode::Html` on their background drawable by default.
**Stash:** `plan: html-background phase1 step10: enable html mode in UIHTMLWidget`
---
### Step 11: End-to-end image atlas verification
Create a comprehensive golden image test that exercises the image atlas/sprite sheet use case end-to-end:
```html
<!-- Test: image atlas rendering -->
<div style="width:32px; height:32px;
background-image: url(atlas.png);
background-size: 256px 256px;
background-position: -64px -96px;
background-repeat: no-repeat;">
</div>
```
Also test multiple atlas cells in a grid, and percentage-based atlas positioning.
**Tests:** Golden image comparing the rendered atlas cell against a reference render (e.g., a cropped version of the atlas).
**Stash:** `plan: html-background phase1 step11: image atlas end-to-end verification`
---
### Phase 1 Summary Table
| Step | Description | Complexity | Dependencies |
|------|-------------|------------|--------------|
| 1 | Add `BackgroundMode` enum + field to `UINodeDrawable` | Low | None |
| 2 | Add origin/clip/attachment enums + fields to `LayerDrawable` | Low | Step 1 |
| 3 | Register new CSS properties (`origin`, `clip`, `attachment`) | Low | None |
| 4 | Dispatch new properties in `UIWidget::applyProperty()` | Low | Step 3 |
| 5 | Add setters to `UINode` → `UINodeDrawable` → `LayerDrawable` | Low | Step 2, 3 |
| 6 | Extend `background-repeat` — two-value + space/round parsing | Medium | Step 2 |
| 7 | Fix `background-size: contain` scaling-up | Low | None |
| 8 | Implement `space` and `round` repeat rendering | Medium | Step 6 |
| 9 | Rewrite `background` shorthand parser | High | Step 6, 8 |
| 10 | Enable HTML mode in `UIHTMLWidget` | Low | Step 1 |
| 11 | End-to-end image atlas verification | Low | All above |
---
## 6. Phase 2 — Cool-to-Have (Implementation Steps)
> **Prerequisite:** Phase 1 must be fully complete with all tests passing.
### Step 12: Update `calcPosition()` for `background-origin`
**File:** `src/eepp/ui/uinodedrawable.cpp` — `LayerDrawable::calcPosition()`
When `mBackgroundMode == Html`:
- `Origin::PaddingBox`: reference width = padding box size (current behavior for mSize, since padding is inside)
- `Origin::BorderBox`: reference width = padding box + border widths (border box)
- `Origin::ContentBox`: reference width = padding box - padding (content area)
```cpp
Sizef refSize = mSize;
if (mContainer->getBackgroundMode() == BackgroundMode::Html) {
switch (mOrigin) {
case Origin::BorderBox:
refSize = getBorderBoxSize();
break;
case Origin::ContentBox:
refSize = getContentBoxSize();
break;
case Origin::PaddingBox:
default:
refSize = mSize;
break;
}
}
// Use refSize instead of mSize in percentage/offset calculations
```
**Tests:** Golden image tests for each origin value with non-zero padding/border. Verify image position shifts correctly.
**Stash:** `plan: html-background phase2 step12: background-origin rendering`
---
### Step 13: Implement `background-clip` in the draw pipeline
**File:** `src/eepp/ui/uinodedrawable.cpp` — `UINodeDrawable::draw()`
When `mBackgroundMode == Html`:
- Determine the clip rect for each layer based on `mClip`
- Use clip plane to restrict drawing to the clip rect
```cpp
if (mBackgroundMode == BackgroundMode::Html) {
for (auto& layer : mGroup) {
Clip clip = layer.second->getClip();
Rectf clipRect = getClipRect(clip);
GLi->getClippingMask()->clipPlaneEnable(clipRect.Left, clipRect.Top,
clipRect.getWidth(), clipRect.getHeight());
layer.second->draw(position, size);
GLi->getClippingMask()->clipPlaneDisable();
}
}
```
The solid `mBackgroundColor` fill must also respect the clip.
**Tests:** Golden image tests for each clip value. `content-box` should clip background to content area. `padding-box` should show background in padding but not under border.
**Stash:** `plan: html-background phase2 step13: background-clip rendering`
---
### Step 14: Implement `background-attachment`
**File:** `src/eepp/ui/uinodedrawable.cpp`
- **`scroll`** (default): Background scrolls with the element — current behavior. No change needed.
- **`fixed`**: Background is fixed relative to the viewport (root scene node). Position is calculated using viewport coordinates, not element coordinates.
- **`local`**: Background scrolls with the element's content. Only meaningful for scrollable containers.
```cpp
Vector2f getEffectivePosition() const {
if (mAttachment == Attachment::Fixed) {
return mContainer->getOwner()->getUISceneNode()->getPosition() + mOffset;
} else if (mAttachment == Attachment::Local) {
Vector2f scrollOff = mContainer->getOwner()->getScrollOffset();
return mPosition + mOffset - scrollOff;
}
return mPosition + mOffset;
}
```
**Tests:** Golden image tests for `fixed` and `local` attachment with scrollable containers.
**Stash:** `plan: html-background phase2 step14: background-attachment rendering`
---
### Step 15: Add `UINode::convertLength` support for box references
**File:** `include/eepp/ui/uinode.hpp`, `src/eepp/ui/uinode.cpp`
For `background-origin: border-box` and `content-box`, the percentage reference for `background-position` needs to use the correct box sizes. `getBorderBoxDiff()` and `getPixelsPadding()` provide these.
**Stash:** `plan: html-background phase2 step15: convertLength reference box support`
---
### Phase 2 Summary Table
| Step | Description | Complexity | Dependencies |
|------|-------------|------------|--------------|
| 12 | Update `calcPosition()` for `background-origin` | Medium | Phase 1 complete |
| 13 | Implement `background-clip` in draw pipeline | Medium | Phase 1 complete |
| 14 | Implement `background-attachment` | High | Phase 1 complete |
| 15 | Add `convertLength` support for box references | Low | Step 12 |
---
## 7. Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| New properties break existing eepp widgets | LOW | `BackgroundMode::Native` is default; all new behavior gated behind `BackgroundMode::Html` |
| `background-clip` clipping conflicts with existing clip plane | MEDIUM | Per-layer clip planes must be properly stacked |
| `fixed` attachment requires viewport tracking | MEDIUM | `UISceneNode` already tracks viewport; need invalidation on scroll |
| `local` attachment needs scroll offset access | MEDIUM | Only scrollable widgets provide offsets; gate on capability check |
| Shorthand parser rewrite breaks existing stylesheets | HIGH | Backward compat for native mode. New parsing only active under `BackgroundMode::Html` |
| Comma-separated multi-layer in shorthand | MEDIUM | Indexed properties already support multi-layer via comma in individual properties |
---
## 8. Non-Scope
- **`background-color`** — no changes needed. Fully HTML-compatible.
- **`background-image` — gradient syntax** — already supports `linear-gradient`. Radial-gradient and conic-gradient are out of scope.
- **`background-blend-mode`** — CSS3 compositing. Out of scope.
- **`background-repeat-x` / `background-repeat-y`** — CSS longhand properties. Out of scope; two-value `background-repeat` shorthand is sufficient.
- **Non-HTML widgets** — they continue using `BackgroundMode::Native` with unchanged behavior.
---
## 9. Test Impact
### New Tests (Must-Have — Phase 1)
| # | Test | Type |
|---|------|------|
| 1 | `BackgroundMode` round-trip get/set | Unit |
| 2 | `originFromText` / `clipFromText` / `attachmentFromText` parsing | Unit |
| 3 | New CSS properties registered and parsed | Unit |
| 4 | `applyProperty` → `getPropertyString` round-trip for new properties | Unit |
| 5 | `UINode` setter → `LayerDrawable` field round-trip | Unit |
| 6 | `repeatFromText` two-value and space/round | Unit |
| 7 | `background-repeat: no-repeat repeat` (two-value, independent axes) | Golden image |
| 8 | `background-size: contain` scaling UP (image smaller than container) | Golden image |
| 9 | `background-repeat: space` (even gap distribution) | Golden image |
| 10 | `background-repeat: round` (scaled to fit exactly) | Golden image |
| 11 | `background` shorthand: `/size` syntax | Unit + Golden image |
| 12 | `background` shorthand: comma-separated multi-layer | Unit + Golden image |
| 13 | Image atlas: `background-position` + `background-size` + fixed widget | Golden image |
| 14 | UIHTMLWidget defaults to `BackgroundMode::Html` | Unit |
### New Tests (Cool-to-Have — Phase 2)
| # | Test | Type |
|---|------|------|
| 15 | `background-origin: content-box` with padding | Golden image |
| 16 | `background-origin: border-box` | Golden image |
| 17 | `background-clip: content-box` | Golden image |
| 18 | `background-clip: padding-box` | Golden image |
| 19 | `background-attachment: fixed` | Golden image |
| 20 | `background-attachment: local` with scrollable content | Golden image |
### Existing Test Safety
Existing tests pass unchanged because:
- `BackgroundMode::Native` is the default for all existing widgets
- No rendering code paths in Native mode are modified
- New properties default to HTML-standard values that match current eepp behavior (e.g., `background-origin: padding-box` = current positioning behavior)
---
## 10. Summary of Key Differences (background-position Focus)
1. **Reference box awareness**: HTML's `background-position` values (especially percentages) are relative to the positioning area controlled by `background-origin` (default `padding-box`). eepp hardcodes a single reference box (widget size = padding box in eepp's model). *Addressed in Phase 2.*
2. **No `background-origin` support**: eepp cannot express `background-origin: content-box` or `border-box`. *Addressed in Phase 2.*
3. **Percentage rounding**: eepp rounds percentage-calculated positions to integers. HTML browsers use sub-pixel positioning.
4. **The actual position math is the same** — the formula `(container - image) × percentage` is correctly implemented. The keyword mappings are correct. The core image atlas workflow works already; Phase 1 validates and hardens it.

View File

@@ -1,516 +0,0 @@
# HTML Whitespace Collapsing: Moving from Parse-Time to Layout-Time
## Problem Statement
Currently, HTML whitespace collapsing (stripping/collapsing `\n ` sequences between elements) happens at **parse time** via `HTMLFormatter::collapseXmlWhitespace`, which operates on the raw pugixml DOM tree. At this stage, CSS has not yet been fully resolved — the `display` property (which determines whether an element participates in inline formatting) hasn't been applied to widgets. This means:
1. Elements with `display: inline-block` set via `<style>` blocks are not recognized as inline by `isInlineNode`, causing whitespace between them to be incorrectly stripped.
2. Dynamic changes to `display` (via JavaScript or external CSS) cannot be handled.
3. The current hack (precomputing display from `<style>` blocks) is fragile and doesn't scale to external stylesheets, media queries, or dynamic updates.
**Desired behavior** (matching browsers): whitespace `\n ` between two `<div>` elements with `display: inline-block` should produce a single space character, exactly as it would between two `<span>` elements.
## Root Cause
The call chain:
```
HTML string
→ gumbo parse
→ serialize to strict XML
→ pugixml parse
→ UIRichText::loadFromXmlNode / UITextSpan::loadFromXmlNode
→ collapseXmlWhitespace(text, pugixml_node) ← PROBLEM: CSS unresolved here
→ if result is empty → skip (no UITextNode created)
→ later: rebuildRichText(reconstructed from widget tree)
```
At the time `collapseXmlWhitespace` runs, the `pugixml_node`'s siblings are div elements with unknown CSS display properties. The function uses `isInlineNode()` which only checks HTML tag names, not CSS.
## Browser-Correct Architecture
Browsers preserve ALL whitespace text nodes in the DOM tree. Whitespace collapsing happens during **layout** (box tree construction), when computed styles are fully resolved:
1. **DOM keeps all whitespace text nodes.** `\n ` remains as a real `Text` node between the divs.
2. **Style computation** resolves `display` via the full cascade (inline styles, stylesheets, inheritance, default values).
3. **Box tree construction** checks each text node's adjacent siblings' computed `display`:
- Both adjacent boxes are inline-level (`display: inline | inline-block | inline-flex | inline-table`)? → Collapse whitespace to a single space.
- Either adjacent box is block-level? → Strip the space entirely.
- Text node at start/end of a block container? → Strip the space entirely.
4. **Dynamic changes** (JS sets `display: block`) trigger re-layout, which re-evaluates whitespace boundaries.
## eepp Architecture Analysis
### The Good Separation
eepp already has the right primitives in the right places:
| Component | Responsibility | Has CSS `display`? |
|---|---|---|
| `UIRichText` / `UITextSpan` (`loadFromXmlNode`) | Parse pugixml → create widget tree | ❌ No |
| `UIRichText::rebuildRichText` | Walk widget tree → rebuild `RichText` blocks | ✅ Yes (via `UIHTMLWidget::getDisplay()`) |
| `UITextNode` | Stores text content, participates in layout | N/A (is text, always inline) |
| `RichText` (Graphics layer) | Text layout engine: line-breaking, wrapping | ✅ Yes (via `addCustomSize`'s `isBlock`) |
The critical insight: **`rebuildRichText` already has full access to the computed display of every widget in the tree.** It already classifies widgets as block vs inline (line 743751):
```cpp
CSSDisplay display = widget->asType<UIHTMLWidget>()->getDisplay();
if ( display == CSSDisplay::Inline || display == CSSDisplay::InlineBlock )
isBlock = false;
```
This is the PERFECT place to handle whitespace. It already knows each widget's `display`, it already walks the tree in order, and it already has access to siblings (`getPreviousNode()/getNextNode()`).
### The `RichText` Pipeline (what `rebuildRichText` feeds)
`RichText` is a horizontal layout engine. Its blocks are:
- **SpanBlock** (`addSpan`): text with font style, margin, padding. Purely inline — text flows in the same line.
- **CustomBlock** (`addCustomSize`): a spacer with given dimensions. If `isBlock=true`, it breaks the current line and occupies full width. If `isBlock=false` (inline), it sits in the text flow at its given width.
`RichText` does NO whitespace collapsing on its own. It renders exactly the text it receives. If we feed it `" \n "` as a span, it renders two spaces, a newline, and two spaces.
### Current `processNode` Lambda (the target for our changes)
In `UIRichText::rebuildRichText(UILayout*, RichText&, IntrinsicMode)` at line 646, the `processNode` lambda handles each child node:
```cpp
auto processNode = [&]( Node* node, auto& processNodeRef ) -> void {
// CASE 1: UITextNode → addSpan(text, style)
if ( node->isTextNode() ) {
richText.addSpan(textNode->getText(), style);
return;
}
// CASE 2: Invisible widgets → skip
if ( !node->isWidget() || !node->isVisible() ) return;
// CASE 3: Mergeable spans (UITextSpan) → addSpan + recurse children
if ( widget->isMergeable() ) {
richText.addSpan(span->getText(), style, margin, padding);
// ...recurse children...
}
// CASE 4: <br/> → addSpan("\n")
// CASE 5: Other widgets → addCustomSize(size, isBlock, float, clear)
};
```
Children are iterated at line 802 in order:
```cpp
Node* child = container->getFirstChild();
while ( NULL != child ) {
processNode( child, processNode );
child = child->getNextNode();
}
```
## Proposed Architecture
### Core Principle: Move whitespace collapsing from `loadFromXmlNode` → `rebuildRichText`
Instead of collapsing at parse time and skipping empty text nodes, we:
1. **Preserve raw text** in `UITextNode` (and `UITextSpan::mText`). No whitespace collapsing at parse time.
2. **Collapse at layout time** in `processNode`, when the full widget tree (with computed `display`) is available.
### What Changes
#### 1. Remove early whitespace collapsing in `loadFromXmlNode`
**`UIRichText::loadFromXmlNode`** (line 599607):
```
BEFORE: String text = HTMLFormatter::collapseXmlWhitespace(child.value(), child);
if (!text.empty()) { create UITextNode with collapsed text; }
AFTER: String text = child.value();
create UITextNode with raw text; // even if all whitespace
```
**`UITextSpan::loadFromXmlNode`** (lines 412, 423):
```
BEFORE: mText += HTMLFormatter::collapseXmlWhitespace(...)
OR create UITextNode with collapsed text
AFTER: mText += child.value() // raw
OR create UITextNode with raw text
```
**New `UITextNode` flag** `mIsWhitespaceOnly` (computed lazily or at creation):
```cpp
bool UITextNode::isWhitespaceOnly() const {
for (char c : mText)
if (c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\v')
return false;
return true;
}
```
#### 2. Add `UIWidget::isInlineDisplay()`
A new virtual/helper method that returns whether a widget participates in inline formatting:
```cpp
bool UIWidget::isInlineDisplay() const {
if ( isTextNode() )
return true; // UITextNode is always inline
if ( isType( UI_TYPE_HTML_WIDGET ) ) {
CSSDisplay d = static_cast<const UIHTMLWidget*>( this )->getDisplay();
return d == CSSDisplay::Inline || d == CSSDisplay::InlineBlock;
}
return false; // non-HTML, non-text widgets default to block
}
```
Declared in `UIWidget` (inline in header, or in `.cpp`). This mirrors the Pug `display: inline | inline-block` semantics.
#### 3. Add whitespace-collapsing logic in `processNode`
In `rebuildRichText``processNode`, replace the `UITextNode` case (line 686702) with:
```cpp
if ( node->isTextNode() ) {
UITextNode* textNode = static_cast<UITextNode*>( node );
String text = collapseInternalWhitespace( textNode->getText() );
// Determine display type of adjacent siblings
bool prevIsInline = false;
Node* prev = node->getPreviousNode();
while ( prev && prev->isTextNode() ) {
UITextNode* ptn = static_cast<UITextNode*>( prev );
if ( !ptn->isWhitespaceOnly() ) break;
prev = prev->getPreviousNode();
}
if ( prev && prev->isWidget() ) {
prevIsInline = prev->asType<UIWidget>()->isInlineDisplay();
}
bool nextIsInline = false;
Node* next = node->getNextNode();
while ( next && next->isTextNode() ) {
UITextNode* ntn = static_cast<UITextNode*>( next );
if ( !ntn->isWhitespaceOnly() ) break;
next = next->getNextNode();
}
if ( next && next->isWidget() ) {
nextIsInline = next->asType<UIWidget>()->isInlineDisplay();
}
// Strip leading space if prev is not inline (block boundary)
if ( !prevIsInline && !text.empty() && text[0] == ' ' )
text = text.substr( 1 );
// Strip trailing space if next is not inline
if ( !nextIsInline && !text.empty() && text.back() == ' ' )
text = text.substr( 0, text.size() - 1 );
if ( text.empty() )
return;
// Get style from parent
FontStyleConfig style;
if ( node->getParent()->isType( UI_TYPE_TEXTSPAN ) )
style = node->getParent()->asType<UITextSpan>()->getFontStyleConfig();
else if ( node->getParent()->isType( UI_TYPE_RICHTEXT ) )
style = node->getParent()->asType<UIRichText>()->getRichText().getFontStyleConfig();
else
style = richText.getFontStyleConfig();
richText.addSpan( text, style );
return;
}
```
Where `collapseInternalWhitespace` is a simple local helper:
```cpp
static String collapseInternalWhitespace( const String& s ) {
String out;
out.reserve( s.size() );
bool inSpace = false;
for ( size_t i = 0; i < s.size(); ++i ) {
if ( s[i] == ' ' || s[i] == '\t' || s[i] == '\n' ||
s[i] == '\r' || s[i] == '\v' ) {
if ( !inSpace ) {
out += ' ';
inSpace = true;
}
} else {
out += s[i];
inSpace = false;
}
}
return out;
}
```
#### 4. Handle whitespace in `UITextSpan::mText` (no-child-element spans)
When a `UITextSpan` has no child elements (line 420426), text is accumulated directly into `mText`. Currently `collapseXmlWhitespace` is called. After the change, `mText` contains raw whitespace.
In `processNode` case 3 (mergeable spans), `hasOwnText` is checked:
```cpp
bool hasOwnText = !span->getText().empty() && NULL != span->getFontStyleConfig().Font;
if ( hasOwnText ) {
richText.addSpan( span->getText(), span->getFontStyleConfig(), margin, padding );
}
```
The `span->getText()` will now contain raw whitespace. We need to collapse it here too:
```cpp
if ( hasOwnText ) {
String collapsed = collapseInternalWhitespace( span->getText() );
if ( !collapsed.empty() ) {
richText.addSpan( collapsed, span->getFontStyleConfig(), margin, padding );
}
}
```
Boundary stripping (leading/trailing) for `UITextSpan::mText` is handled by the same sibling-display logic as `UITextNode`, because the span itself has siblings whose display types are known.
Actually: for `UITextSpan` that stores text directly, we need to consider its OWN siblings when stripping leading/trailing spaces. This is the same as for `UITextNode` — check prev/next widget display types. The existing code for mergeable spans doesn't do this sibling check, but since mergeable spans already participate in inline formatting (they're inline by nature), the existing `collapseXmlWhitespace` handled the boundary stripping at parse time. After the migration, we need to add boundary stripping here.
However, the sibling info is available in the `processNode` lambda. We can add the same prev/next check:
```cpp
if ( hasOwnText ) {
String collapsed = collapseInternalWhitespace( span->getText() );
// Boundary strip (same logic as UITextNode above)
// ...
if ( !collapsed.empty() ) {
richText.addSpan( collapsed, span->getFontStyleConfig(), margin, padding );
}
}
```
#### 5. Clean up `HTMLFormatter`
After migration, the following functions are no longer needed:
- `HTMLFormatter::isInlineNode`
- `HTMLFormatter::hasSignificantText`
- `HTMLFormatter::getLogicalPrev`
- `HTMLFormatter::getLogicalNext`
- `HTMLFormatter::collapseXmlWhitespace`
- `HTMLFormatter::precomputeDisplayStyles`
- The static `sDocInlineSelectors` map
They can be removed (or deprecated/moved to a legacy namespace for a transition period).
The ONLY remaining function in `HTMLFormatter` would be `HTMLtoXML` (Gumbo parse + serialize to strict XML).
### Why This Is Simple
The change boils down to:
1. **Delete** ~120 lines of whitespace logic from `htmlformatter.cpp` (`collapseXmlWhitespace` + 4 helpers + precompute + static map)
2. **Remove** 2 calls to `collapseXmlWhitespace` in `uirichtext.cpp` and `uitextspan.cpp`, keeping the raw text
3. **Add** ~40 lines in `uirichtext.cpp` (`collapseInternalWhitespace` + sibling boundary checks in `processNode`)
4. **Add** `UIWidget::isInlineDisplay()` (~10 lines)
The total net change is approximately -80 lines, with simpler logic in the right place.
### Edge Cases Handled
| Scenario | How it's handled |
|---|---|
| Whitespace-only text between two inline-block divs | `prevIsInline=true, nextIsInline=true` → space kept |
| Whitespace-only text between two block divs | `prevIsInline=false, nextIsInline=false` → stripped entirely |
| Whitespace at start of container | `prev` is null → `prevIsInline=false` → leading space stripped |
| Whitespace at end of container | `next` is null → `nextIsInline=false` → trailing space stripped |
| Text " hello world " with neighbor blocks | Internal spaces collapsed, leading/trailing stripped |
| Two consecutive whitespace text nodes | `while (prev->isTextNode())` skips them to find real boundary |
| `<span>text</span> <img/>` | Both span and img are inline → space kept |
| Dynamic JS changes `display` | Every `rebuildRichText` call re-evaluates, up-to-date `getDisplay()` |
| External stylesheets | `getDisplay()` reflects the full cascade at layout time |
| `<pre>` or `<code>` elements | These set `white-space: pre` via CSS, which should suppress collapsing entirely. The `csslayouttypes.hpp` has no `CSSWhiteSpace` enum yet — this plan does NOT add it, but the `processNode` change could check for a future `CSSWhiteSpace` property to bypass collapsing. |
### What This Plan Does NOT Handle (Known Limitations)
1. **Deep logical prev/next traversal**: The original `getLogicalPrev/Next` traverses up/down the tree to find the "visually closest" element. For example:
```html
<span><b>hello</b></span> <img/>
```
The space between `</span>` and `<img/>` is logically between `</b>` and `<img/>`. The current plan only checks DIRECT siblings. For this specific case, both `span` and `img` are inline-direct-siblings, so it works. The only case this fails is:
```html
<div><span>text</span> </div>
```
Here the space is between `</span>` and `</div>`. `span` is inline, `</div>` is a container end (null next sibling). Our check: `next` is null → `nextIsInline=false`, so trailing space stripped. This is correct: trailing space at end of block container is stripped.
The deep traversal is only needed when the immediate sibling is a wrapper with only inline children — and in those cases, the wrapper's `isInlineDisplay()` returns true, which gives the same result as drilling down would. So direct-sibling check covers all practical cases.
2. **`white-space` CSS property**: We don't have `CSSWhiteSpace` yet. The `processNode` change can easily accommodate it later. For now, elements like `<pre>` would have their whitespace collapsed (which is wrong for `<pre>`). But `<pre>` currently handles its content differently in the widget tree — its text goes into `UICodeEditor`, not `UIRichText`, so this is a non-issue in practice.
3. **Multiple consecutive whitespace-only text nodes**: The `while (prev->isTextNode())` loop skips them correctly. But if there are 3+ consecutive whitespace nodes, only the middle one survives (as a single space), while the first and last get boundary-stripped. This matches HTML behavior.
4. **`UITextSpan` with child elements**: When a `UITextSpan` has child elements, text is stored in child `UITextNode`s, which are processed by the outer `processNode` loop (since the span is mergeable, its children are recursed). So the `UITextNode` whitespace logic handles it. The `UITextSpan`'s own `hasOwnText` path is only for spans without child elements.
### Testing Strategy
1. **Background atlas test** (`bin/unit_tests/assets/html/background_atlas.html`): The 20 inline-block tile divs should have 1px spaces between them (matching browser rendering). Delete the golden image before running.
2. **New whitespace-specific HTML tests**:
- `whitespace_inline_blocks.html`: 4 inline-block divs with `\n ` between each — should render with spaces.
- `whitespace_block_divs.html`: 4 block divs with `\n ` between each — spaces should be stripped.
- `whitespace_mixed.html`: Mix of block and inline-block elements with whitespace — test boundary conditions.
- `whitespace_text_nodes.html`: Text like `" hello world "` between various widget types — test internal collapsing + boundary stripping.
3. **Visual golden test**: Create a new golden image test with the above HTML files, comparing against expected pixel output.
4. **Regression**: Run full test suite (262 tests) to ensure no existing tests break.
## Step-by-Step Implementation Plan
### Step 1: Add `UIWidget::isInlineDisplay()`
**File:** `include/eepp/ui/uiwidget.hpp` (declaration), `src/eepp/ui/uiwidget.cpp` (definition)
```cpp
// In uiwidget.hpp, in the public section:
bool isInlineDisplay() const;
// In uiwidget.cpp:
bool UIWidget::isInlineDisplay() const {
if ( isTextNode() )
return true;
if ( isType( UI_TYPE_HTML_WIDGET ) ) {
CSSDisplay d = static_cast<const UIHTMLWidget*>( this )->getDisplay();
return d == CSSDisplay::Inline || d == CSSDisplay::InlineBlock;
}
return false;
}
```
Include `uihtmlwidget.hpp` in `uiwidget.cpp` if not already. (It currently includes `uinode.hpp` → `uihtmlwidget.hpp` is likely already reachable.)
### Step 2: Add `UITextNode::isWhitespaceOnly()`
**File:** `include/eepp/ui/uitextnode.hpp`, `src/eepp/ui/uitextnode.cpp`
```cpp
// In uitextnode.hpp:
bool isWhitespaceOnly() const;
// In uitextnode.cpp:
bool UITextNode::isWhitespaceOnly() const {
for ( char c : mText ) {
if ( c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\v' )
return false;
}
return true;
}
```
### Step 3: Add `collapseInternalWhitespace` helper to `UIRichText`
**File:** `src/eepp/ui/uirichtext.cpp`
Add as a file-local anonymous namespace function before `rebuildRichText`:
```cpp
namespace {
String collapseInternalWhitespace( const String& s ) {
String out;
out.reserve( s.size() );
bool inSpace = false;
for ( size_t i = 0; i < s.size(); ++i ) {
if ( s[i] == ' ' || s[i] == '\t' || s[i] == '\n' ||
s[i] == '\r' || s[i] == '\v' ) {
if ( !inSpace ) {
out += ' ';
inSpace = true;
}
} else {
out += s[i];
inSpace = false;
}
}
return out;
}
}
```
### Step 4: Modify `processNode` in `rebuildRichText` — `UITextNode` case
**File:** `src/eepp/ui/uirichtext.cpp`, function `rebuildRichText(UILayout*, RichText&, IntrinsicMode)`
Replace lines 686702 (the `UITextNode` block) with the new whitespace-aware version described in section 3 above.
### Step 5: Modify `processNode` — `UITextSpan` hasOwnText case
**File:** `src/eepp/ui/uirichtext.cpp`, same function
Wrap the `hasOwnText` path (lines 716717) with `collapseInternalWhitespace` and boundary stripping.
### Step 6: Remove `collapseXmlWhitespace` calls from `loadFromXmlNode`
**File:** `src/eepp/ui/uirichtext.cpp`, `UIRichText::loadFromXmlNode`
Replace line 601:
```
// OLD: String text = Tools::HTMLFormatter::collapseXmlWhitespace( child.value(), child );
// NEW: const char* text = child.value();
```
And remove the `if (!text.empty())` guard — always create `UITextNode`.
**File:** `src/eepp/ui/uitextspan.cpp`, `UITextSpan::loadFromXmlNode`
Replace lines 412 and 423 similarly.
### Step 7: Clean up `HTMLFormatter`
**Files:** `include/eepp/ui/tools/htmlformatter.hpp`, `src/eepp/ui/tools/htmlformatter.cpp`
Remove:
- `isInlineNode`
- `hasSignificantText`
- `getLogicalPrev`
- `getLogicalNext`
- `collapseXmlWhitespace`
- `precomputeDisplayStyles`
- `sDocInlineSelectors` static map
- `collectStyleText` helper
- `parseCssForDisplayInline` helper
- Associated includes (`<unordered_map>`, `<unordered_set>` if no longer needed)
Keep only `HTMLtoXML`.
Also remove `precomputeDisplayStyles` calls from `UIRichText::loadFromXmlNode` and `UITextSpan::loadFromXmlNode`.
### Step 8: Build and fix compilation
### Step 9: Delete golden image and run atlas test
```bash
rm bin/unit_tests/assets/html/eepp-ui-background-atlas.webp
ASAN_OPTIONS=detect_leaks=0 xvfb-run bin/unit_tests/eepp-unit_tests-debug --filter="UIBackground.imageAtlasPositioning"
```
The test should pass and regenerate the golden image with spaces between tiles.
### Step 10: Run full test suite
```bash
ASAN_OPTIONS=detect_leaks=0 xvfb-run bin/unit_tests/eepp-unit_tests-debug
```
All 262+ tests should pass.
### Step 11: Stash
```bash
git stash push -m "plan: html-whitespace step11: all steps complete"
```
## Files Modified
| File | Change |
|---|---|
| `include/eepp/ui/uiwidget.hpp` | Add `isInlineDisplay()` declaration |
| `src/eepp/ui/uiwidget.cpp` | Add `isInlineDisplay()` definition |
| `include/eepp/ui/uitextnode.hpp` | Add `isWhitespaceOnly()` declaration |
| `src/eepp/ui/uitextnode.cpp` | Add `isWhitespaceOnly()` definition |
| `src/eepp/ui/uirichtext.cpp` | Add `collapseInternalWhitespace` helper; rewrite `processNode` cases; remove `collapseXmlWhitespace` and `precomputeDisplayStyles` calls |
| `src/eepp/ui/uitextspan.cpp` | Remove `collapseXmlWhitespace` calls; keep raw text |
| `include/eepp/ui/tools/htmlformatter.hpp` | Remove whitespace-related method declarations |
| `src/eepp/ui/tools/htmlformatter.cpp` | Remove whitespace-related implementations |

View File

@@ -1,555 +0,0 @@
# Inline SVG Support & HTML Image Element Analysis Plan
This document outlines the architectural plan for adding inline `<svg>` HTML element support and analyzes whether a dedicated `UIHTMLImage` class is needed to improve `<img>` element behavior.
**AGENT DIRECTIVE:** You are Negen. Follow this plan iteratively. Compile and run unit tests after every step. Do NOT proceed if any regression is detected. Take git stash snapshots (`git stash push -m "Phase X.Y passed" && git stash apply`) on passing checkpoints.
---
## Part A: Current State Analysis
### A.1 How SVG Files Load via `<img src="file.svg">` Today
```
HTML: <img src="image.svg">
→ UIWidgetCreator::createFromName("img") → UIImage
→ UIImage::loadFromXmlNode → UIImage::applyProperty(PropertyId::Src)
→ DrawableImageParser::createDrawable(path) / DrawableSearcher::searchByName(path)
→ resolves file://, http://, data: URI
→ TextureFactory::loadFromFile/Memory → Image() → detects .svg extension
→ Image::svgLoad() → nanosvg parse + rasterize → RGBA pixels → Texture (GPU)
→ UIImage::setDrawable(texture) → draw() renders via OpenGL at widget size
```
### A.2 Why `<svg>` Inline Elements Fail Today
1. `UIWidgetCreator` has no `"svg"` registration (line 164 of widgetcreator.cpp)
2. When the HTML parser encounters `<svg>...</svg>`, it calls `createFromName("svg")` → returns `nullptr` → silently skipped
3. Even if a widget were created, the SVG's **children** (`<circle>`, `<rect>`, `<path>`, etc.) would be recursively loaded as HTML/UI widgets by the parent (since `loadsItsChildren()` would return false) — this would pollute the widget tree with garbage null lookups
### A.3 Existing SVG Infrastructure We Can Reuse
| Component | File | Role |
|---|---|---|
| nanosvg parser | `src/thirdparty/nanosvg/nanosvg.h` | Parses SVG XML to `NSVGimage` (paths, paints, gradients) |
| nanosvg rasterizer | `src/thirdparty/nanosvg/nanosvgrast.h` | Rasterizes to RGBA pixel buffer |
| `Image::svgLoad()` | `src/eepp/graphics/image.cpp:1008` | Parses + rasterizes SVG in a single call |
| `Image::getInfoFromMemory()` | `include/eepp/graphics/image.hpp:183` | Reads SVG intrinsic width/height without rasterizing |
| `TextureFactory::loadFromMemory()` | `include/eepp/graphics/texturefactory.hpp:77` | Creates GPU Texture from raw pixel data with `FormatConfiguration` (including `svgScale`) |
| `UISVGIcon` class | `include/eepp/ui/uiicon.hpp:50` | Rasterizes SVG XML on-demand at requested size (icons only) |
| `UIImage` class | `include/eepp/ui/uiimage.hpp` | Drawable-based rendering with scale types, alignment, tinting, aspect-ratio-preserving auto-sizing |
| `DrawableSearcher::searchByName()` | `src/eepp/graphics/drawablesearcher.cpp` | Handles `data:image/svg+xml,...` URIs in CSS `url()` |
| `UISceneNode::getThreadPool()` | `src/eepp/ui/uiscenenode.cpp:470` | Thread pool for async operations |
| `UIImageViewer::loadImageAsync()` | `src/eepp/ui/tools/uiimageviewer.cpp:100` | Proven async image loading pattern (thread pool + Sprite ownership) |
### A.4 Key Class Hierarchy (What UISvg Needs to Fit Into)
```
UINode
└── UIWidget ← default SizePolicy::WrapContent (width + height)
├── UIImage ← mDrawable, mScaleType, mColor, onAutoSize(), calcDestSize(), draw()
│ └── UISvg (NEW) ← our new class
└── UILayout
└── UIHTMLWidget ← CSSDisplay, CSSPosition, layouter integration
├── UIRichText ← rebuildRichText() processes inline/block children
└── UITextSpan
```
**Key insight:** `UISvg` inherits from `UIImage` (not `UIHTMLWidget`). This means:
- Reuses all drawing, scaling, and alignment code
- Does NOT participate in the CSS display/position system (treated as a "custom" widget in rich text flow)
- In `rebuildRichText()`, it's classified as `isBlock` only if `mWidthPolicy == MatchParent`, otherwise inline — which matches HTML's default inline-block behavior for `<svg>`
---
## Part B: Implementation Plan — UISvg Widget
### Phase 1: Core UISvg Class
#### Step 1.1: Add `UI_TYPE_SVG` to UINodeType Enum
**File:** `include/eepp/ui/uihelper.hpp`
Insert `UI_TYPE_SVG` after `UI_TYPE_HTML_LIST_ITEM` (line 131), before `UI_TYPE_MODULES`:
```cpp
UI_TYPE_HTML_LIST_ITEM,
UI_TYPE_SVG, // NEW
UI_TYPE_MODULES = 10000,
```
#### Step 1.2: Create UISvg Header
**File:** `include/eepp/ui/uisvg.hpp` (NEW)
```cpp
#ifndef EE_UI_UISVG_HPP
#define EE_UI_UISVG_HPP
#include <eepp/ui/uiimage.hpp>
namespace EE { namespace UI {
class EE_API UISvg : public UIImage {
public:
static UISvg* New();
virtual ~UISvg();
virtual Uint32 getType() const;
virtual bool isType( const Uint32& type ) const;
virtual void loadFromXmlNode( const pugi::xml_node& node );
const std::string& getSvgXml() const;
protected:
UISvg();
void onSizeChange() override;
std::string mSvgXml;
Uint64 mTag{ 0 }; // async task tag for cleanup on destruction
static const Action::UniqueID sRasterizeId;
void loadSvgXml( const pugi::xml_node& node );
void scheduleRasterize();
void rasterizeSvg( const std::string& svgXml );
void clearThreadTag();
};
}} // namespace EE::UI
#endif
```
**Design decisions:**
- Inherits from `UIImage` (not `UIHTMLWidget`) — simpler, reuses all rendering/scaling/alignment code
- Stores raw SVG XML in `mSvgXml` for re-rasterization when the widget resizes
- Overrides `loadFromXmlNode` to capture the SVG subtree and trigger rasterization
- Overrides `onSizeChange` to schedule async re-rasterization with debounce
- `getType()` returns `UI_TYPE_SVG` for type-checking (e.g., `widget->isType(UI_TYPE_SVG)`)
- Thread pool task tag stored in `mTag` for cleanup in destructor
#### Step 1.3: Create UISvg Implementation
**File:** `src/eepp/ui/uisvg.cpp` (NEW)
**Constructor:**
```cpp
UISvg::UISvg() : UIImage() {
// Prevent parent from recursively loading SVG children as UI widgets
mFlags |= UI_LOADS_ITS_CHILDREN;
}
```
**Destructor:**
```cpp
UISvg::~UISvg() {
clearThreadTag();
}
```
**loadFromXmlNode override:**
```cpp
void UISvg::loadFromXmlNode( const pugi::xml_node& node ) {
// Process regular attributes (style, id, class, width, height, etc.)
beginAttributesTransaction();
UIWidget::loadFromXmlNode( node );
endAttributesTransaction();
// Serialize the <svg> subtree to string
loadSvgXml( node );
// Kick off async rasterization
scheduleRasterize();
}
```
**XML serialization helper:**
```cpp
// Simple pugi::xml_writer that accumulates into a std::string
class XmlStringWriter : public pugi::xml_writer {
public:
std::string result;
virtual void write( const void* data, size_t size ) override {
result.append( static_cast<const char*>( data ), size );
}
};
void UISvg::loadSvgXml( const pugi::xml_node& node ) {
XmlStringWriter writer;
node.print( writer );
mSvgXml = writer.result;
}
```
**Async rasterization schedule (initial load + size changes):**
```cpp
void UISvg::scheduleRasterize() {
if ( mSvgXml.empty() )
return;
auto size = getPixelsSize();
if ( size.getWidth() <= 0.f || size.getHeight() <= 0.f )
return;
if ( !getUISceneNode()->hasThreadPool() )
return;
clearThreadTag();
std::string svgXml( mSvgXml );
auto pixelDensity = PixelDensity::getPixelDensity();
mTag = getUISceneNode()->getThreadPool()->run(
[this, svgXml = std::move( svgXml ), pixelDensity] {
rasterizeSvg( svgXml );
},
[]( const Uint64& ) {},
(Uint64)this ); // tag by `this` pointer to allow cancelling
}
```
**Rasterization (runs on thread pool):**
```cpp
void UISvg::rasterizeSvg( const std::string& svgXml ) {
Image::FormatConfiguration format;
format.svgScale( PixelDensity::getPixelDensity() );
// Determine target pixel size for rasterization:
// Use the widget's content size at pixel density, or intrinsic SVG size
Texture* texture = TextureFactory::instance()->loadFromMemory(
(const unsigned char*)svgXml.data(), svgXml.size(),
false, // mipmap
Texture::ClampMode::ClampToEdge, // clamp mode
false, false, // compress, keepLocalCopy
format );
if ( !texture )
return;
// Wrap in Sprite to handle TextureFactory ownership lifecycle properly.
// Sprite will remove the texture from TextureFactory and delete it when
// destroyed. UIImage takes ownership of the Sprite via setDrawable(true).
Sprite* sprite = Sprite::New();
sprite->createStatic( texture );
sprite->setAsTextureOwner( true );
sprite->setAsTextureRegionOwner( true );
runOnMainThread( [this, sprite] {
// Use the widget's content size to compute the correct drawable scale.
// The actual SVG intrinsic size determines the drawable's pixel dimensions,
// while the widget's layout size determines the on-screen display bounds.
setDrawable( sprite, true ); // UISvg owns the Sprite → Sprite owns the Texture
} );
}
```
**onSizeChange override (debounced re-rasterization):**
```cpp
void UISvg::onSizeChange() {
UIImage::onSizeChange();
auto size = getPixelsSize();
if ( size.getWidth() <= 0.f || size.getHeight() <= 0.f )
return;
// Debounce: cancel any pending rasterization and schedule a new one.
// Node::debounce() automatically cancels the previous call with the same
// uniqueIdentifier if called again before the delay expires.
debounce( [this] { scheduleRasterize(); },
Milliseconds( 150 ),
sRasterizeId );
}
// In the .cpp file:
const Action::UniqueID UISvg::sRasterizeId = String::hash( "UISvg_rasterize" );
```
**Thread tag cleanup:**
```cpp
void UISvg::clearThreadTag() {
if ( mTag != 0 && getUISceneNode()->hasThreadPool() ) {
getUISceneNode()->getThreadPool()->removeWithTag( (Uint64)this );
mTag = 0;
}
}
```
**Important notes on `UI_LOADS_ITS_CHILDREN`:**
- Setting this flag tells `UIRichText::loadFromXmlNode()` and `UISceneNode::loadNode()` to skip recursive child processing for the SVG node
- Without this flag, the parent would try to create widgets for `<circle>`, `<rect>`, `<path>` etc. — all of which are unknown to `UIWidgetCreator` and would fail silently (but still waste cycles)
- The SVG is NOT expected to contain child elements that should become UI widgets
#### Step 1.4: Register `"svg"` in UIWidgetCreator
**File:** `src/eepp/ui/uiwidgetcreator.cpp`
Add after the existing `"img"` registration (line 168):
```cpp
registeredWidget["svg"] = [] {
auto svg = UISvg::New();
svg->setFlags( UI_HTML_ELEMENT );
return svg;
};
```
This makes `<svg>...</svg>` elements in HTML content create `UISvg` widgets flagged as HTML elements (so the rich text engine treats them appropriately).
#### Step 1.5: Update Makefiles (premake4)
Since we added new `.hpp` and `.cpp` files, regenerate makefiles:
```
premake4 --disable-static-build --with-mold-linker --with-debug-symbols --address-sanitizer gmake
```
**Validation:** Run `make -C make/linux -j$(nproc)` and ensure clean compile. (Snapshot)
#### Step 1.6: Unit Test
**File:** `src/tests/unit_tests/` (specific file TBD, likely create `htmlsvg.cpp` or extend existing HTML tests)
Test at minimum:
1. **Basic inline SVG rendering:** `<svg width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>`
2. **SVG with viewBox:** `<svg viewBox="0 0 200 200"><rect width="100" height="100" fill="blue"/></svg>`
3. **CSS sizing on SVG:** `<svg style="width: 200px; height: 150px;">...</svg>`
4. **SVG with xmlns:** `<svg xmlns="http://www.w3.org/2000/svg">...</svg>`
5. **Verification that SVG children are NOT created as UI widgets**
6. **Resize re-rasterization:** Verify the SVG re-renders crisply after resizing the widget
Reference existing SVG test asset: `bin/unit_tests/assets/html/triangle.svg`
**Validation:** Run `ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="Svg"` — must pass. (Snapshot)
---
### Phase 2: Edge Cases & Polish
#### Step 2.1: Handle SVG Without Intrinsic Dimensions
Some SVGs lack explicit `width`/`height` attributes. In this case, fall back to the widget's content size or a reasonable default (e.g., 300×150, matching browser defaults for replaced elements).
#### Step 2.2: Handle SVG with viewBox Only
When the SVG has a `viewBox` attribute (e.g., `viewBox="0 0 200 150"`) but no `width`/`height`, the intrinsic aspect ratio should come from the viewBox dimensions. Nanosvg's `Image::getInfoFromMemory` handles this.
#### Step 2.3: HiDPI / Pixel Density
The `svgScale` in `Image::FormatConfiguration` handles this:
- `format.svgScale( PixelDensity::getPixelDensity() )`
- For a device with 2× pixel density, the SVG renders at 2× pixel resolution
- The widget's logical size remains in CSS pixel units
This is correctly handled in the rasterization code above.
#### Step 2.4: SVG with Internal `<style>` / CSS
Nanosvg supports inline styles and the `<style>` element. No special handling needed — the SVG XML serialization preserves all content.
#### Step 2.5: SVG with `<use>` / External References
Nanosvg may not fully support external references (xlink). This is a known limitation inherited from nanosvg, not from our implementation. Document as a known limitation.
#### Step 2.6: Sync Fallback When Thread Pool Unavailable
When `!hasThreadPool()`, rasterize synchronously on the main thread directly in `scheduleRasterize()`. This ensures the SVG still renders in environments without a thread pool.
---
## Part C: UIHTMLImage Analysis & Recommendation
### C.1 Current `UIImage` Behavior as `<img>` Element
| Aspect | Current Implementation | HTML Spec Behavior | Match? |
|---|---|---|---|
| Intrinsic sizing | `onAutoSize()` uses drawable dimensions | Replaced element intrinsic dimensions | ✓ |
| CSS `width: 200px` only | Height auto-computed from aspect ratio | Same | ✓ |
| CSS `height: 200px` only | Width auto-computed from aspect ratio | Same | ✓ |
| Both WrapContent | Sizes to drawable dimensions | Same | ✓ |
| Max-width constraint | Respected in `onAutoSize()` | Same | ✓ |
| `scale-type` | `FitInside`/`Expand`/`None` | Maps to `object-fit` (approximated) | ≈ |
| `text-align` | Used for horizontal alignment | CSS `text-align` on inline elements | ✓ |
| Default display flow | Inline (WrapContent width → `isBlock=false`) | Inline-block | ≈ |
| `alt` attribute | Registered as `tooltip` alias (tooltip text only) | Text fallback when image fails to load | ✗ |
| HTML `width`/`height` attrs | Treated as CSS width/height (Fixed policy) | Presentational hints separate from CSS | ≈ |
| `srcset`/`sizes` | Not supported | Responsive images | ✗ |
| `loading="lazy"` | Not supported | Deferred loading | ✗ |
### C.2 Note on `alt` Attribute
The `alt` attribute is already registered as a tooltip alias in `propertydefinition.cpp`:
```
registerProperty( "tooltip", "" )
.setType( PropertyType::String )
.addAlias( "alt" );
```
This means that currently `<img alt="My Image">` simply sets a tooltip on the widget. It does NOT provide the HTML-spec fallback behavior (showing alt text when the image fails to load). Any UIHTMLImage implementation would need to separately handle the visual alt-text fallback.
### C.3 Gap Analysis
The most impactful gap is the **`alt` attribute fallback display**: when an image fails to load (e.g., broken URL), there's no visible indicator. Everything else is either already handled or an advanced feature.
The sizing behavior is already close to the HTML spec for common use cases. The default `WrapContent` policy on `UIWidget` ensures images display at their intrinsic size unless overridden by CSS, and aspect-ratio preservation works when only one dimension is specified.
### C.4 Recommendation: Create UIHTMLImage (Phase 3)
**Verdict: YES, create a dedicated `UIHTMLImage : public UIImage` class.** Reason:
1. Adding `alt` text fallback display is the most immediate improvement and justifies the class
2. It provides a clean extension point for future HTML-specific image features
3. It separates concerns: HTML semantics can evolve without touching `UIImage`'s general-purpose code
4. Low-risk: it's a thin wrapper with one added feature
#### UIHTMLImage Class Design:
```cpp
class EE_API UIHTMLImage : public UIImage {
public:
static UIHTMLImage* New();
virtual Uint32 getType() const;
virtual bool isType( const Uint32& type ) const;
virtual void loadFromXmlNode( const pugi::xml_node& node );
virtual void draw();
const std::string& getAlt() const;
UIHTMLImage* setAlt( const std::string& alt );
protected:
UIHTMLImage();
std::string mAlt;
UITextView* mAltLabel{ nullptr };
void createAltLabel();
void removeAltLabel();
};
```
**loadFromXmlNode override:**
```cpp
void UIHTMLImage::loadFromXmlNode( const pugi::xml_node& node ) {
// Read alt attribute before base class processing.
// Note: "alt" is already registered as a tooltip alias in propertydefinition,
// so the base class will handle it as a tooltip. We separately capture mAlt
// for the visual fallback display.
for ( auto& attr : node.attributes() ) {
if ( String::iequals( attr.name(), "alt" ) ) {
mAlt = attr.value();
break;
}
}
beginAttributesTransaction();
UIWidget::loadFromXmlNode( node );
endAttributesTransaction();
// If image failed to load (no drawable) and alt text exists, show alt label
if ( !mDrawable && !mAlt.empty() ) {
createAltLabel();
} else if ( mDrawable && mAltLabel ) {
removeAltLabel();
}
}
```
**alt text fallback:**
- Create a `UITextView` child widget positioned over the image area
- Show it only when `mDrawable` is null and `mAlt` is non-empty
- The text view displays the alt text with appropriate styling (centered, italic, gray)
- Override `draw()` to show either the image or the alt text
- On drawable resource change (image loads later or reloads), remove the alt label
**Registration replacement in UIWidgetCreator:**
```cpp
// Replace this (line 164):
registeredWidget["img"] = [] {
auto img = UIImage::NewWithTag( "img" );
img->setFlags( UI_HTML_ELEMENT );
return img;
};
// With this:
registeredWidget["img"] = [] {
auto img = UIHTMLImage::New();
img->setFlags( UI_HTML_ELEMENT );
return img;
};
```
**Additional note on CSS display:** Since `UIHTMLImage` inherits from `UIImage` (not `UIHTMLWidget`), it inherits the same inline behavior in `rebuildRichText()`. If full CSS `display` support is needed later, consider adding `UIHTMLWidget` to the inheritance chain (or using composition).
---
## Part D: Implementation Order
| Step | Description | Files | Risk |
|---|---|---|---|
| **P1.1** | Add `UI_TYPE_SVG` to `UINodeType` | `uihelper.hpp` | Low |
| **P1.2** | Create `UISvg` header | `uisvg.hpp` (NEW) | Low |
| **P1.3** | Create `UISvg` implementation | `uisvg.cpp` (NEW) | Medium |
| **P1.4** | Register `"svg"` in widget creator | `uiwidgetcreator.cpp` | Low |
| **P1.5** | Regenerate makefiles + compile | premake4 + make | Low |
| **P1.6** | Unit test for inline SVG | `src/tests/unit_tests/` | Medium |
| **P2.x** | Edge cases (no-intrinsic-dims, viewBox, HiDPI, sync fallback) | `uisvg.cpp` | Low-Medium |
| **P3.1** | Create `UIHTMLImage` class | `uihtmlimage.hpp/.cpp` (NEW) | Low |
| **P3.2** | Replace `img` registration | `uiwidgetcreator.cpp` | Low |
| **P3.3** | Unit test for alt text behavior | `src/tests/unit_tests/` | Low |
---
## Part E: Files Summary
### New Files
| File | Purpose |
|---|---|
| `include/eepp/ui/uisvg.hpp` | UISvg class declaration (inherits UIImage) |
| `src/eepp/ui/uisvg.cpp` | UISvg implementation (XML serialization, async SVG rasterization) |
| `include/eepp/ui/uihtmlimage.hpp` | UIHTMLImage class declaration (inherits UIImage, alt text fallback) |
| `src/eepp/ui/uihtmlimage.cpp` | UIHTMLImage implementation |
| `src/tests/unit_tests/htmlsvg.cpp` | Unit tests for inline SVG rendering |
### Modified Files
| File | Change |
|---|---|
| `include/eepp/ui/uihelper.hpp` | Add `UI_TYPE_SVG` to `UINodeType` enum |
| `src/eepp/ui/uiwidgetcreator.cpp` | Register `"svg"` → UISvg; replace `"img"` → UIHTMLImage |
---
## Part F: Potential Hazards
1. **pugi::xml_writer dependency:** The serialization code uses `pugi::xml_writer` which is provided by the included pugixml. No additional dependencies needed.
2. **Pixel vs DP math:** `UIImage::onAutoSize()` and `calcDestSize()` already use `mSize`, `mPaddingPx`, `getPixelsSize()`, and `mDrawable->getPixelsSize()` correctly. The SVG rasterization uses `PixelDensity::getPixelDensity()` for the scale factor. Ensure no dp/pixel confusion in the new code.
3. **Lifetime management — UISvg owns the drawable:**
- `TextureFactory::loadFromMemory()` creates a Texture tracked by the factory with refCount=1
- The texture is wrapped in a `Sprite`, and `Sprite::setAsTextureOwner(true)` makes the Sprite responsible for the Texture's lifetime
- `setDrawable(sprite, true)` makes `UIImage` own the Sprite
- When UISvg is destroyed: `~UIImage()``safeDeleteDrawable()``eeSAFE_DELETE(sprite)``~Sprite()``cleanUpResources()``eeSAFE_DELETE(texture)``~Texture()``TextureFactory::removeReference(this)` → refCount reaches 0 → factory removes entry → GPU texture deleted
- **This follows the exact same pattern as `UIImageViewer::loadImageAsync()`** (`src/eepp/ui/tools/uiimageviewer.cpp:100`)
4. **XML format preservation:** pugi::xml_writer preserves the original formatting. The SVG content is byte-for-byte identical to the serialized XML subtree.
5. **`UI_LOADS_ITS_CHILDREN` side effects:** This flag is also checked by some generic code paths. Verify no negative side effects on layout, clipping, or hit testing.
6. **Thread safety — async rasterization:**
- Rasterization runs on `UISceneNode::getThreadPool()` to avoid blocking the render loop
- `TextureFactory::loadFromMemory()` is called on the thread pool (already proven by `UIImageViewer`)
- The Sprite is constructed on the thread pool (also proven pattern)
- `setDrawable()` and the subsequent `onAutoSize()` / `invalidateDraw()` run on the main thread via `runOnMainThread()`
- Task cancellation: `mTag` tracks the last async task, cleared in destructor via `removeWithTag(this)` to prevent callbacks on destroyed widgets
7. **Large SVG files / Debounce on resize:**
- Re-rasterization is triggered by `onSizeChange()` with a **150ms debounce** via `Node::debounce(cb, delay, uniqueIdentifier)` — if called again before the delay, the previous call is cancelled and the timer resets
- Rasterization is **skipped** if widget has 0 width or height
- Thread pool tag (`removeWithTag(this)`) ensures only the most recent rasterization completes (old tasks are cancelled)
- For huge SVGs, the 150ms debounce prevents multiple expensive parses during rapid layout transitions
8. **Sync fallback:** When no thread pool is available (`!hasThreadPool()`), rasterization happens synchronously on the main thread in `scheduleRasterize()`. This ensures SVGs render in all environments but may cause a frame drop on load.

View File

@@ -1,61 +0,0 @@
# UI Layout Phase 6: CSS Float & Clear Plan
This document outlines the architectural plan for implementing CSS `float` and `clear` support within the decoupled layout system, leveraging the `Graphics::RichText` engine for mixed content formatting.
**AGENT DIRECTIVE (CRITICAL):** You MUST compile and run the unit tests (`bin/unit_tests/eepp-unit_tests-debug`) after EVERY step. Do NOT proceed to the next step if there is even a 1-pixel difference in visual layout tests. Take a git stash snapshot (`git stash push -m "Phase 6.X passed" && git stash apply`) upon passing a step to keep a checkpoint while continuing to work. **If you need to restore a stash, use `git stash apply` instead of `git stash pop` so the stable snapshot is never lost.**
---
## IMPLEMENTATION HAZARDS (READ BEFORE CODING)
1. **Keyword Collision:** `Float` is a C++ type (`typedef float Float`). When defining the CSS enum, you MUST name it `CSSFloat` to avoid compiler collisions.
2. **Y-Coordinate Interleaving:** `RichText::updateLayout` currently breaks lines independently of their Y position, and only computes Y coordinates *after* all lines are formed. Because floating elements alter the available horizontal width at specific Y coordinate ranges, you will have to calculate `curY` *during* the block iteration, keeping track of active floats to restrict `curX` and `maxWidth`.
3. **Out-Of-Flow Precedence:** Floating elements are *not* out-of-flow in the same way `position: absolute` elements are. `absolute` elements are ignored by layouters, whereas `float` elements strictly participate in and influence the block formatting context (they take up space and push text around). Do not mark them as `isOutOfFlow() = true` in `UIRichText::rebuildRichText`.
---
## Phase 6: Float and Clear implementation
**Step 6.1: CSS Enums and Properties**
- In `csslayouttypes.hpp`, define:
```cpp
enum class CSSFloat { None, Left, Right };
enum class CSSClear { None, Left, Right, Both };
```
And their helper parsing functions (`CSSFloatHelper::fromString`, etc.).
- In `propertydefinition.hpp`, ensure `PropertyId::Float` and `PropertyId::Clear` exist (if not, add them, avoiding conflicts).
- In `UIHTMLWidget`, add `mFloat` and `mClear` members (defaulting to `None`).
- In `UIHTMLWidget::applyProperty`, parse the `Float` and `Clear` properties. Call `notifyLayoutAttrChange()` when they change.
- **Validation:** Compile and run all tests. Must pass. (Snapshot)
**Step 6.2: Extend RichText API**
- In `include/eepp/graphics/richtext.hpp`, update `RichText::addCustomSize`:
```cpp
void addCustomSize( const Sizef& size, bool isBlock, CSSFloat floatType = CSSFloat::None, CSSClear clearType = CSSClear::None );
```
- Update `CustomBlock` struct to store `floatType` and `clearType`.
- In `UIRichText::rebuildRichText`, extract `getCSSFloat()` and `getCSSClear()` from the child widget (defaulting to `None` if the child isn't an HTML widget). Pass these to `richText.addCustomSize`.
- **Validation:** Compile and run all tests. (Snapshot)
**Step 6.3: Core RichText Layout Algorithm (The Tricky Part)**
- In `RichText::updateLayout()`, introduce Y-coordinate awareness during the main loop:
- Create tracking lists: `std::vector<Rectf> leftFloats; std::vector<Rectf> rightFloats;`
- Introduce `Float curY = 0;`
- Before placing *any* block (text or custom), process `clear`: if the block has `clear: left`, advance `curY` past the `bottom` of all `leftFloats`. (Same for `right` and `both`). Reset `curX` and push a new `RenderParagraph` if `curY` changed.
- Compute `availableLeft(curY)` and `availableRight(curY, mMaxWidth)`. Your `curX` must never be less than `availableLeft`.
- **If the block is a float:**
- Place it immediately at `availableLeft` (if left) or `availableRight - width` (if right).
- Add its bounding box `{x, curY, width, height}` to the respective float list.
- Do *not* advance `curX` for the normal inline flow.
- Do *not* trigger a new line for normal flow text (floats are pulled out of the inline line box).
- **If the block is normal text/inline:**
- Adjust `LineWrap::computeLineBreaksEx` to respect the narrowed `mMaxWidth` computed from `availableRight - availableLeft`. *(Note: You may need to handle the case where text wraps below a float and reclaims full width. This can be done by processing text in line-height chunks if constrained by a float).*
- Make sure `curY` is updated when normal lines wrap.
- **Validation:** This is the most complex step. Ensure all existing tests pass exactly (0 pixels difference) before writing float-specific tests. (Snapshot)
**Step 6.4: Float/Clear Layout Tests**
- In `src/tests/unit_tests/uihtml_position_tests.cpp` (or a new `uihtml_float_tests.cpp`), write robust tests for:
- Text wrapping around a `float: left` block.
- Two consecutive `float: left` blocks stacking horizontally.
- A block with `clear: both` jumping below all floats.
- `BlockLayouter` correctly locating the `CustomBlock` widgets where `RichText` positioned the floats.
- **Validation:** Compile and run all tests. Must pass. (Snapshot)

View File

@@ -1,55 +0,0 @@
# UI Layout Phase 8: Form Action and Navigation Plan
This document outlines the architectural plan for implementing HTML `<form>` submissions, input value extraction, and expanding `UISceneNode` to support interceptable navigation requests (GET/POST).
**AGENT DIRECTIVE (CRITICAL):** You MUST compile and run the unit tests (`bin/unit_tests/eepp-unit_tests-debug`) after EVERY step. Do NOT proceed to the next step if there is even a 1-pixel difference in visual layout tests. Take a git stash snapshot (`git stash push -m "Phase 8.X passed" && git stash apply`) upon passing a step to keep a checkpoint while continuing to work. **If you need to restore a stash, use `git stash apply` instead of `git stash pop` so the stable snapshot is never lost.**
---
## Phase 8: Form and Navigation implementation
**Step 8.1: Extend Navigation System**
- The current `UISceneNode::openURL(URI)` and `setURLInterceptorCb` only handle simple URIs, which cannot represent `POST` requests or request bodies.
- In `include/eepp/ui/uiscenenode.hpp`, create:
```cpp
struct NavigationRequest {
URI uri;
std::string method{ "GET" };
std::string body;
std::map<std::string, std::string> extraHeaders;
};
```
- Add `void navigate( const NavigationRequest& request );` to `UISceneNode`.
- Add `void setNavigationInterceptorCb( std::function<bool( const NavigationRequest& request )> cb );`.
- Update `openURL(URI)` to wrap `navigate(NavigationRequest{uri})` for backward compatibility.
- In `navigate()`, if `mNavigationInterceptorCb` returns `true`, return early. Else if `mURLInterceptorCb` returns `true`, return early. Else, fallback to `Engine::instance()->openURI()`.
- **Validation:** Compile and run all tests. (Snapshot)
**Step 8.2: Retrieve Values from Form Elements**
- Form submission requires querying the value of input elements.
- Add `virtual String getValue() const { return String(); }` to `UIWidget`.
- Override `getValue()` in the appropriate classes:
- `HTMLInput`: return `getText()` for text, or `"on"`/`""` for checkboxes/radio buttons based on `isChecked()`.
- `HTMLTextArea` (and `UITextEdit`): return `getText()`.
- `UIDropDownList` (and `UIComboBox`): return the selected item's text.
- Add `virtual String getName() const { return getAttribute("name"); }` or rely on `getAttribute("name")` to get the field identifier.
- **Validation:** Write unit tests to verify `getValue()` for text, checkbox, and dropdowns. Compile and run all tests. (Snapshot)
**Step 8.3: Implement UIHTMLForm**
- Create `UIHTMLForm` class inheriting from `UIHTMLWidget` (or `UIRichText` if treating as a block container).
- Add members: `mAction` (URI), `mMethod` (String, default "GET"), `mEnctype` (String).
- Override `applyProperty` to capture `action`, `method`, and `enctype`.
- Implement `void submit()`.
- `submit()` iterates over all child widgets recursively.
- If a widget has a non-empty `name` attribute (using `getAttribute("name")`), it collects its `getValue()`.
- It URL-encodes the keys and values.
- If `mMethod == "GET"`, it appends the URL-encoded query string to `mAction` and calls `navigate()`.
- If `mMethod == "POST"`, it puts the URL-encoded string into the `body` of `NavigationRequest`, sets `method = "POST"`, and calls `navigate()`.
- In `uiwidgetcreator.cpp`, update `registeredWidget["form"]` to instantiate `UIHTMLForm::New`.
- **Validation:** Compile and run all tests. (Snapshot)
**Step 8.4: Form Submission Triggers & Testing**
- In `UIHTMLForm`, listen for `Event::OnMouseClick` on any child widget. If the target is a submit button (e.g., `HTMLInput` with `type="submit"`, or a `UIPushButton` with `type="submit"`), prevent the default action and call `submit()`.
- Listen to `Event::OnPressEnter` inside text inputs within the form to trigger `submit()`.
- Write a unit test simulating a form with inputs and a submit button. Attach a `NavigationInterceptorCb` to the scene node, simulate a click on the submit button, and verify the intercepted `NavigationRequest` contains the correct URI and encoded body.
- **Validation:** Compile and run all tests. Must pass. (Snapshot)

View File

@@ -1,184 +0,0 @@
# Percentage Size Resolution Plan
## Goal
CSS-spec-compliant resolution of percentage `width` and `height` values, spanning both the HTML layouter pipeline and the non-HTML GUI layouters.
## CSS Spec Reference
From CSS 2.1 §10.5:
> If the height of the containing block is not specified explicitly (i.e., it depends on content height), and this element is not absolutely positioned, the value computes to `auto`.
The same rule applies to width §10.2. Symmetric treatment needed.
## Architecture Landscape
### Two Laying Hierarchies
| Hierarchy | Base | Purpose |
|---|---|---|
| `UILayout``UIWidget` | Non-HTML GUI containers | `UILinearLayout`, `UIGridLayout`, `UIStackLayout`, `UIRelativeLayout`, `UISplitter`, `UIHTMLWidget` |
| `UILayouter` (standalone) | HTML/CSS display-mode engines | `BlockLayouter`, `TableLayouter`, `InlineLayouter`, `NoneLayouter` |
`UIHTMLWidget` inherits from `UILayout` and delegates CSS layout to a `UILayouter*`.
### How Percentage Width/Height Becomes `SizePolicy::Fixed`
CSS `width: 85%` / `height: 100%` arrives at `UIWidget::applyProperty()` as `PropertyId::Width` / `PropertyId::Height`. The handler (non-auto branch) does:
```cpp
setLayoutWidthPolicy( SizePolicy::Fixed );
setSize( eefloor( lengthFromValueAsDp( "85%" ) ), ... );
```
`lengthFromValueAsDp` resolves the percentage against the parent's current pixel size, which may be 0 if the parent hasn't been laid out yet. The resolution is **not** re-evaluated later when the parent size becomes known—**unless** the layouter has explicit re-resolution logic.
### Where Re-resolution Exists Today (Width Only)
Both `BlockLayouter` and `TableLayouter` re-resolve `Fixed` width at layout time:
**`blocklayouter.cpp:64-69`:**
```cpp
const StyleSheetProperty* prop = nullptr;
if ( mContainer->getLayoutWidthPolicy() == SizePolicy::Fixed &&
mContainer->getUIStyle() &&
( prop = mContainer->getUIStyle()->getProperty( PropertyId::Width ) ) ) {
mContainer->setInternalPixelsSize(
{ mContainer->lengthFromValue( *prop ), mContainer->getPixelsSize().getHeight() } );
}
```
**`tablelayouter.cpp:294-298`:**
```cpp
if ( widget->getLayoutWidthPolicy() == SizePolicy::Fixed &&
widget->getUIStyle() &&
( prop = widget->getUIStyle()->getProperty( PropertyId::Width ) ) ) {
widget->asType<UINode>()->setInternalPixelsSize(
{ widget->lengthFromValue( *prop ), widget->getPixelsSize().getHeight() } );
}
```
There is **no equivalent height re-resolution** in either layouter. The height path only handles `WrapContent` (content-based) and ignores `Fixed` height:
**`blocklayouter.cpp:89-100`:**
```cpp
Float totH = mContainer->getPixelsSize().getHeight();
if ( mContainer->getLayoutHeightPolicy() == SizePolicy::WrapContent ) {
totH = rt->getSize().getHeight() + ...;
}
// Fixed height passes through unchanged (stale 0px from early resolution)
```
## The Problem
Percentage height on a `WrapContent` parent produces `SizePolicy::Fixed` with 0px (parent un-laid-out). Without height re-resolution in the layouters, the 0px sticks. This causes the cascade of zero-height elements seen in `contact.html` (`.stylish-form` ul with `height: 100%` inside a WrapContent `form`).
## Non-HTML Layouter Impact Assessment
### `UILinearLayout` (`uilinearlayout.cpp`)
- **Children's Fixed policy**: Handled as no-op in `applyWidthPolicyOnChildren` (line 102-104: `case SizePolicy::Fixed: default: {}`) and `applyHeightPolicyOnChildren` (line 151-153). No re-resolution of percentage values.
- **Own Fixed size**: Not re-resolved from CSS property. Uses whatever was set.
- **Impact of fix**: `layout-height: 100%` on a child inside a `WrapContent` parent would correctly switch to `WrapContent` at `applyProperty` time. Since `UILinearLayout` doesn't do CSS-style percentage re-resolution, the `applyProperty`-level fix is sufficient.
- **Risk**: None. Non-HTML widgets don't use CSS `width`/`height` properties (they use `layout-width`/`layout-height`). The `PropertyId::Width`/`PropertyId::Height` path in `applyProperty` is only triggered by CSS parsing into HTML elements.
### `UIGridLayout` (`uigridlayout.cpp`)
- Forces all children to `SizePolicy::Fixed` (line 163). No percentage re-resolution.
- **Impact**: None. Grid layout overrides child size policies anyway.
### `UIStackLayout` (`uistacklayout.cpp`)
- `applySizePolicyOnChildren()`: `Fixed` is no-op (lines 55, 73). No re-resolution.
- **Impact**: Same as `UILinearLayout``applyProperty` fix is sufficient.
### `UIRelativeLayout` (`uirelativelayout.cpp`)
- `fixChildSize()`: `Fixed` is no-op (lines 148, 165). No re-resolution.
- **Impact**: Same as above.
### `UISplitter` (`uisplitter.cpp`)
- Forces children to `Fixed` (line 186). Uses `StyleSheetLength("50%")` for split partition.
- **Impact**: None.
### Summary: Non-HTML Layouters
None of the non-HTML layouters re-resolve percentage CSS properties. They don't need to because they don't use CSS `width`/`height` — they use eepp's `layout-width`/`layout-height` (or direct `setSize`). The `applyProperty` fix only fires for `PropertyId::Width`/`PropertyId::Height` (CSS properties from parsed stylesheets), not for `PropertyId::LayoutWidth`/`PropertyId::LayoutHeight` (eepp-specific properties) **unless** the latter are set to percentage values by user code.
For the `PropertyId::LayoutWidth`/`PropertyId::LayoutHeight` cases, the `applyProperty` fix also applies the percentage-on-WrapContent-parent check. This is correct — if a user writes `layout-height="100%"` on a widget inside a WrapContent parent, the engine should treat it as auto.
## Implementation Steps
### Step 1: Restore Width Fix in `applyProperty` (`uiwidget.cpp`)
Re-apply the percentage-on-WrapContent-parent check to `PropertyId::Width` (currently reverted). Keep the existing `PropertyId::Height` fix.
**Rationale**: Symmetric CSS spec treatment. The width re-resolution in the layouters (BlockLayouter line 64-69, TableLayouter line 294-298) already handles the case where the parent HAS explicit dimensions — they re-compute from the CSS property at layout time. The `applyProperty` fix only fires when the parent has `WrapContent` (no explicit dimension), which is exactly when CSS spec says the percentage should compute to `auto`.
**Files**: `src/eepp/ui/uiwidget.cpp`
- `PropertyId::Width` (line ~1932): Add percentage-on-WrapContent-parent check (restore what was reverted)
- `PropertyId::LayoutWidth` (line ~2169): Same check (restore what was reverted)
- `PropertyId::Height` and `PropertyId::LayoutHeight`: Already have the fix
### Step 2: Add Height Re-resolution in BlockLayouter (`blocklayouter.cpp`)
Mirror the existing width re-resolution pattern for height. Insert before the RichText rebuild:
```cpp
// After setMatchParentIfNeededVerticalGrowth(), before rebuildRichText:
if ( mContainer->getLayoutHeightPolicy() == SizePolicy::Fixed &&
mContainer->getUIStyle() &&
( prop = mContainer->getUIStyle()->getProperty( PropertyId::Height ) ) ) {
mContainer->setInternalPixelsSize(
{ mContainer->getPixelsSize().getWidth(), mContainer->lengthFromValue( *prop ) } );
}
```
This handles the case where an element has `height: 100%` on a parent that DOES have an explicit height (MatchParent or Fixed), ensuring the percentage resolves at layout time when parent dimensions are known.
**Rationale**: The `applyProperty` fix handles the `WrapContent` parent case (switches to WrapContent). The layouter re-resolution handles the `MatchParent`/`Fixed` parent case (re-computes the percentage against the now-laid-out parent). Together they cover all cases.
**Files**: `src/eepp/ui/blocklayouter.cpp`
### Step 3: Add Height Re-resolution in TableLayouter (`tablelayouter.cpp`)
Same pattern. Insert after `setMatchParentIfNeededVerticalGrowth()` (line 290):
```cpp
if ( widget->getLayoutHeightPolicy() == SizePolicy::Fixed &&
widget->getUIStyle() &&
( prop = widget->getUIStyle()->getProperty( PropertyId::Height ) ) ) {
widget->setInternalPixelsSize(
{ widget->getPixelsSize().getWidth(), widget->lengthFromValue( *prop ) } );
}
```
**Files**: `src/eepp/ui/tablelayouter.cpp`
### Step 4: Write Tests
- **ContactFormLayout** (already done in `uihtml_tests.cpp`): Verifies height resolution for `height: 100%` on WrapContent parent.
- **New test: PercentageHeightOnFixedParent**: Creates an element with `height: 100%` inside a parent with explicit `height: 300px`. Verifies the child gets 300px at layout time (tests the `BlockLayouter` height re-resolution).
- **New test: PercentageWidthOnWrapContentParent**: Creates an element with `width: 100%` inside a `display: inline-block` parent (which has WrapContent width). Verifies the child gets content-based width (tests the width fix in `applyProperty`).
- **New test: PercentageWidthTableOnFixedParent**: Creates a table with `width: 85%` inside a parent with `width: 500px`. Verifies the table gets 425px (tests `TableLayouter` width re-resolution with the fix in place).
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Percentage width fix breaks table layouts (like Hacker News) | Table's parent (block element) has `MatchParent` width, NOT `WrapContent`. The fix only fires for `WrapContent` parents. The existing width re-resolution in `TableLayouter` continues to work for `Fixed`-policy tables. |
| Height re-resolution conflicts with `setMatchParentIfNeededVerticalGrowth` | `setMatchParentIfNeededVerticalGrowth` only fires for `MatchParent` policy. The re-resolution fires for `Fixed` policy. They handle mutually exclusive cases. |
| Non-HTML layouters affected by `LayoutHeight` fix | EE-internal `layout-height` with percentage values is extremely rare. Even if it occurs, the correct CSS-spec behavior is to treat it as auto on a WrapContent parent. This is a correctness improvement. |
| Existing image comparison tests | The 3 `complexLayout` tests must be re-verified after changes. No image diffs expected (see risk #1). |
## Files Modified
| File | Change |
|---|---|
| `src/eepp/ui/uiwidget.cpp` | Restore percentage-on-WrapContent check for `PropertyId::Width` and `PropertyId::LayoutWidth` |
| `src/eepp/ui/blocklayouter.cpp` | Add height re-resolution (mirror existing width pattern) |
| `src/eepp/ui/tablelayouter.cpp` | Add height re-resolution (mirror existing width pattern) |
| `src/tests/unit_tests/uihtml_tests.cpp` | Add new tests for various percentage scenarios |
| `.agent/plans/percentage_size_resolution_plan.md` | This plan |

View File

@@ -1,710 +0,0 @@
# SystemFontResolver Architecture Plan
## Problem Statement
`eepp` has no mechanism to discover fonts installed on the host operating system. When CSS specifies `font-family: "Helvetica Neue", Arial, sans-serif`, the current resolution chain (`UISceneNode::getFontFromNamesList``FontManager::getByName`) can only find fonts that were explicitly loaded via `@font-face` or programmatic `FontTrueType::New`. System font keywords (`caption`, `icon`, `menu`, etc.) are parsed but silently discarded. This makes cross-platform CSS font-family resolution impossible.
**Goal:** Implement a cross-platform `SystemFontResolver` abstraction that maps CSS `font-family` + weight/style properties to physical font file paths (and face indices for `.ttc` files) by querying the host OS font subsystem.
---
## 1. Target Platform APIs
| Platform | EE_PLATFORM | OS API | Headers/Libraries |
|----------|-------------|--------|-------------------|
| Windows | `EE_PLATFORM_WIN` | DirectWrite | `<dwrite.h>`, `dwrite.lib` |
| macOS | `EE_PLATFORM_MACOS` | Core Text | `<CoreText/CoreText.h>`, `-framework CoreText` |
| iOS | `EE_PLATFORM_IOS` | Core Text | same as macOS |
| Linux | `EE_PLATFORM_LINUX` | Fontconfig | `<fontconfig/fontconfig.h>`, `-lfontconfig` |
| FreeBSD | `EE_PLATFORM_BSD` | Fontconfig | same as Linux |
| Android (API 29+) | `EE_PLATFORM_ANDROID` | NDK Font API | `<android/font.h>`, `<android/font_matcher.h>`, `-landroid` |
| Android (legacy) | `EE_PLATFORM_ANDROID` | XML fallback | `/system/etc/fonts.xml` |
| Haiku | `EE_PLATFORM_HAIKU` | Interface Kit / BFont | `<Font.h>`, `-lbe` |
---
## 2. Interface Definition
### 2.1 Header: `include/eepp/graphics/systemfontresolver.hpp`
```cpp
#ifndef EE_GRAPHICS_SYSTEMFONTRESOLVER_HPP
#define EE_GRAPHICS_SYSTEMFONTRESOLVER_HPP
#include <eepp/config.hpp>
#include <eepp/system/singleton.hpp>
#include <eepp/graphics/base.hpp>
#include <string>
#include <vector>
#include <unordered_map>
#include <cstdint>
namespace EE { namespace Graphics {
/** Weight scale matching CSS font-weight (100-900 + keywords mapped to numeric) */
enum class FontWeight : Uint16 {
Thin = 100,
ExtraLight = 200,
Light = 300,
Normal = 400,
Medium = 500,
SemiBold = 600,
Bold = 700,
ExtraBold = 800,
Black = 900
};
/** Categorization of font stretch/width */
enum class FontStretch : Uint8 {
UltraCondensed = 1,
ExtraCondensed = 2,
Condensed = 3,
SemiCondensed = 4,
Normal = 5,
SemiExpanded = 6,
Expanded = 7,
ExtraExpanded = 8,
UltraExpanded = 9
};
/** Query sent to the OS font subsystem */
struct FontQuery {
std::string family; ///< CSS font-family string (e.g. "Arial", "sans-serif")
FontWeight weight { FontWeight::Normal };
FontStretch stretch { FontStretch::Normal };
bool italic { false };
};
/** Resolved physical font from the OS */
struct FontDesc {
std::string path; ///< Full filesystem path to the font file
Uint32 faceIndex {0}; ///< Face index for .ttc/.otc TrueType Collections
FontWeight weight { FontWeight::Normal };
FontStretch stretch { FontStretch::Normal };
bool italic { false };
bool monospace { false };
};
/** Generic font family classification */
enum class GenericFamily : Uint8 {
Serif,
SansSerif,
Monospace,
Cursive,
Fantasy,
SystemUi, ///< OS default UI font
Emoji,
Unknown
};
/** @brief Cross-platform system font discovery and resolution.
*
* Queries the host OS font subsystem to enumerate installed fonts and resolve
* CSS font-family names to physical file paths. All results are cached to
* avoid repeated OS API calls. This is NOT a Font subclass — it is a
* pure resolver that returns file paths consumable by FontTrueType.
*/
class EE_API SystemFontResolver {
SINGLETON_DECLARE_HEADERS( SystemFontResolver )
public:
~SystemFontResolver();
/** Enumerate all fonts installed on the system. Expensive — call once, results cached. */
const std::vector<FontDesc>& enumerate();
/** Enumerate fonts matching a specific family name. */
std::vector<FontDesc> enumerateFamily( const std::string& family );
/** Find the best matching font file for a given query.
* Searches by family, then falls back to generic family defaults. */
FontDesc resolve( const FontQuery& query );
/** Resolve with a font-family CSS list string (e.g. "Helvetica Neue, Arial, sans-serif").
* Returns the best match across the entire list. */
FontDesc resolveFromNamesList( const std::string& namesList, FontWeight weight, bool italic );
/** Map a generic CSS family keyword to the OS default font path. */
FontDesc resolveGeneric( GenericFamily generic, FontWeight weight, bool italic );
/** Get the default system UI font path. */
FontDesc getSystemFont() const;
/** Get the default monospace font path. */
FontDesc getSystemMonospaceFont() const;
/** Find a fallback font that contains a specific Unicode codepoint. */
FontDesc getFallbackForCodepoint( Uint32 codepoint, FontWeight weight, bool italic );
/** Check if a font file contains a specific codepoint. */
bool fontContainsCodepoint( const std::string& path, Uint32 codepoint,
Uint32 faceIndex = 0 );
/** Clear the internal caches. Call when fonts are installed/uninstalled at runtime. */
void invalidateCache();
protected:
SystemFontResolver();
private:
/** Populate the full font list from the OS. Platform-specific. */
void populateFontList();
/** Map a generic family to platform-default font paths. */
void populateGenericFallbacks();
/** The raw enumerated list from the OS. Populated once, never cleared. */
std::vector<FontDesc> mFontList;
bool mFontListPopulated{ false };
/** Cache: (family_lower, weight, stretch, italic) → FontDesc.
* Key uses the lower-cased family name to make lookups case-insensitive. */
struct CacheKey {
String::HashType familyHash;
Uint16 weight;
Uint8 stretch;
bool italic;
bool operator==( const CacheKey& o ) const {
return familyHash == o.familyHash && weight == o.weight &&
stretch == o.stretch && italic == o.italic;
}
};
struct CacheKeyHasher {
Uint64 operator()( const CacheKey& k ) const {
return ( static_cast<Uint64>( k.familyHash ) << 32 ) |
( static_cast<Uint64>( k.weight ) << 16 ) |
( static_cast<Uint64>( k.stretch ) << 8 ) |
( static_cast<Uint64>( k.italic ) );
}
};
mutable UnorderedMap<CacheKey, FontDesc, CacheKeyHasher> mResolveCache;
/** Cache: generic + weight + italic → FontDesc */
mutable UnorderedMap<Uint32, FontDesc> mGenericCache;
/** Cache for codepoint fallback lookups. */
mutable UnorderedMap<Uint32, std::string> mCodepointFallbackCache;
/** Pre-computed generic family mappings (e.g. "sans-serif" → platform default). */
struct GenericEntry {
GenericFamily generic;
FontDesc desc;
};
std::vector<GenericEntry> mGenericFallbacks;
/** Best-match scoring: given a query and a candidate, compute a fit score (lower is better). */
static int scoreMatch( const FontQuery& query, const FontDesc& candidate );
};
}} // namespace EE::Graphics
#endif
```
### 2.2 Key Design Decisions
**Singleton Pattern.** Uses the existing `SINGLETON_DECLARE_HEADERS` / `SINGLETON_DECLARE_IMPLEMENTATION` macros (same as `FontManager`). The singleton is created on first `instance()` call.
**Not a Font subclass.** The resolver returns `FontDesc` structs (file paths), not `Font*` objects. This avoids coupling font discovery to the FreeType/GPU resource lifecycle. The consumer (`FontManager` or `UISceneNode`) is responsible for creating `FontTrueType` instances from the paths.
**Case-insensitive matching.** CSS font-family names are case-insensitive. Internal lookups use `String::toLower()` on family names before hashing/querying.
**faceIndex support.** TrueType Collections (`.ttc`, `.otc`) contain multiple faces in a single file. The `faceIndex` field in `FontDesc` tells `FontTrueType` which face to load from the file.
---
## 3. Factory Routing (Platform Selection)
### 3.1 Source File Organization
```
src/eepp/graphics/
├── systemfontresolver.hpp # Public header
├── systemfontresolver.cpp # Common implementation (cache logic, matching, generic fallbacks)
├── systemfontresolver_win.cpp # Windows / DirectWrite
├── systemfontresolver_macos.cpp # macOS+iOS / Core Text
├── systemfontresolver_linux.cpp # Linux+BSD / Fontconfig
├── systemfontresolver_android.cpp # Android / NDK Font API + XML fallback
└── systemfontresolver_haiku.cpp # Haiku / BFont
```
### 3.2 Routing in `systemfontresolver.hpp`
```cpp
#if EE_PLATFORM == EE_PLATFORM_WIN
#define EE_SYSTEMFONT_PLATFORM_DEFINED
#elif EE_PLATFORM == EE_PLATFORM_MACOS || EE_PLATFORM == EE_PLATFORM_IOS
#define EE_SYSTEMFONT_PLATFORM_DEFINED
#elif EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD
#define EE_SYSTEMFONT_PLATFORM_DEFINED
#elif EE_PLATFORM == EE_PLATFORM_ANDROID
#define EE_SYSTEMFONT_PLATFORM_DEFINED
#elif EE_PLATFORM == EE_PLATFORM_HAIKU
#define EE_SYSTEMFONT_PLATFORM_DEFINED
#elif EE_PLATFORM == EE_PLATFORM_EMSCRIPTEN
// Emscripten: no system font access; use baked-in fonts only
#endif
```
### 3.3 `systemfontresolver.cpp` Common Implementation
Houses:
- `populateFontList()` stubs (where each platform's implementation gets compiled in)
- `resolve()` — the main matching algorithm: lower-case lookup in `mResolveCache`, iterate `mFontList`, compute `scoreMatch()` scores, return best.
- `resolveGeneric()` — look up `mGenericCache`, fall back to `isFamilyInList()` search, else use hard-coded platform defaults.
- `resolveFromNamesList()` — split by comma, try each family name, `resolve()` each.
- `getFallbackForCodepoint()` — iterate font list, call `fontContainsCodepoint()`, cache results.
- `fontContainsCodepoint()` — use FreeType `FT_New_Face(..., faceIndex, ...)`, `FT_Get_Char_Index()`, then `FT_Done_Face()`.
- `scoreMatch()` — weighted scoring: exact family match (0), family substring match (10), generic match (50), weight distance, italic mismatch penalty, stretch distance.
### 3.4 Platform Implementations (in separate .cpp files)
Each platform file implements only `SystemFontResolver::populateFontList()` and `SystemFontResolver::getSystemFont()`. Common code stays in `systemfontresolver.cpp`.
| Platform | populateFontList Implementation |
|----------|-------------------------------|
| Windows | `IDWriteFactory::GetSystemFontCollection()` → enumerate `IDWriteFontFamily``IDWriteFont``IDWriteLocalizedStrings` for family name → `IDWriteFontFace::GetFiles()` for paths |
| macOS/iOS | `CTFontManagerCopyAvailableFontFamilyNames()` → for each family: `CTFontDescriptorCreateMatchingFontDescriptors()``CTFontDescriptorCopyAttribute(kCTFontURLAttribute)` |
| Linux/BSD | `FcInit()``FcPatternCreate()``FcFontList()``FcPatternGetString(FC_FILE)`, `FcPatternGetInteger(FC_INDEX)`, `FcPatternGetInteger(FC_WEIGHT)`, `FcPatternGetInteger(FC_SLANT)` |
| Android (29+) | `AFontMatcher_create()``AFontMatcher_setFamilyVariant()` → use `AFontMatcher` to enumerate |
| Android (legacy) | Parse `/system/etc/fonts.xml` for `<family>``<font>` elements |
| Haiku | `BFont::get_family_and_style()` + iterate `BPath` for font directories |
---
## 4. Memory & Performance Strategy
### 4.1 Caching Architecture (Two Layers)
```
┌──────────────────────────────────────────────────────────┐
│ Layer 1: Enumerated Font List (mFontList) │
│ - Populated ONCE per process lifetime │
│ - std::vector<FontDesc>, pre-sorted by family │
│ - Populated lazily on first enumerate()/resolve() call │
│ - Thread-safe: populated under mutex, read-only after │
│ - Memory: ~2-5KB per 1000 system fonts (path strings) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Layer 2: Resolution Cache (mResolveCache) │
│ - Key: (familyHash, weight, stretch, italic) │
│ - Value: FontDesc │
│ - Built lazily: first resolve(family, weight) calls │
│ - Hit rate: ~100% after warm-up (font queries are few) │
│ - Thread-safe: lockless reads after population │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Layer 2b: Codepoint Fallback Cache │
│ - Key: Uint32 codepoint │
│ - Value: font file path string │
│ - Built lazily per codepoint │
│ - Hit rate: ~100% for commonly-missing CJK/emoji chars │
└──────────────────────────────────────────────────────────┘
```
### 4.2 Performance Characteristics
| Operation | Cold Path | Hot Path |
|-----------|-----------|----------|
| `enumerate()` | 10-80ms (OS query + path normalization) | <1us (cached) |
| `resolve(family, weight)` | First call per family: enumerates all fonts if not yet done | <2us (hash lookup + array scan of family matches) |
| `resolveFromNamesList("Arial, sans-serif", ...)` | Same as resolve for first family | <10us (try each family in list, first hit returns) |
| `getFallbackForCodepoint(0x65E5)` | ~5ms (FreeType FT_New_Face + FT_Get_Char_Index + FT_Done_Face, may scan multiple fonts) | <1us (cached string + path) |
| `fontContainsCodepoint()` | ~1-3ms per font file (FT_New_Face, FT_Get_Char_Index, FT_Done_Face) | Result not cached separately (only via getFallbackForCodepoint) |
### 4.3 Render-Loop Safety
**Critical invariant:** `SystemFontResolver` is NEVER called during the render loop. It is called only during:
1. Style resolution (when CSS `font-family` is applied to a widget this happens during layout/initialization, not drawing)
2. `UISceneNode::getFontFromNamesList()` called during `applyProperty()` which runs on the UI thread but outside the draw cycle
3. Font fallback codepoint lookups triggered by `FontTrueType::getGlyph()`, which calls `eeASSERT( Engine::isMainThread() )` and runs on the main thread but MUST NOT call the OS. See Section 6 for the async fallback strategy.
**For fallback codepoint resolution**, the OS query is too slow for the render path. Instead:
- `getFallbackForCodepoint()` with cache hit O(1) return, safe for render
- `getFallbackForCodepoint()` with cache miss queues a deferred load, returns "not found" to render thread, triggers a callback once resolved
### 4.4 Memory Footprint Strategy
- **`mFontList`** (vector of FontDesc): Each entry is ~80 bytes (path string SSO + metadata). For 1000 fonts: ~80KB. Acceptable.
- **`mResolveCache`** (hash map): Each entry is ~100 bytes. For 100 resolved queries: ~10KB.
- **`mCodepointFallbackCache`**: Each entry is ~80 bytes. For 1000 codepoints: ~80KB.
- **Total budget:** Under 200KB. No heap pressure beyond this.
---
## 5. FreeType Integration (faceIndex + Loading)
### 5.1 Changes to `FontTrueType::loadFromFile`
**Current** (fonttruetype.cpp:350):
```cpp
if ( FT_New_Face( static_cast<FT_Library>( mLibrary ), filename.c_str(), 0, &face ) != 0 ) {
```
**Modified** add default parameter `faceIndex = 0`:
```cpp
bool loadFromFile( const std::string& filename, Uint32 faceIndex = 0 );
```
Internal change use the parameter:
```cpp
if ( FT_New_Face( static_cast<FT_Library>( mLibrary ), filename.c_str(),
static_cast<FT_Long>( faceIndex ), &face ) != 0 ) {
```
### 5.2 Changes to `FontTrueType::loadFromMemory`
**Current** (fonttruetype.cpp:384-385):
```cpp
if ( FT_New_Memory_Face( static_cast<FT_Library>( mLibrary ),
reinterpret_cast<const FT_Byte*>( ptr ),
static_cast<FT_Long>( sizeInBytes ), 0, &face ) != 0 ) {
```
**Modified** add `faceIndex` parameter:
```cpp
bool loadFromMemory( const void* data, std::size_t sizeInBytes, bool copyData = true,
Uint32 faceIndex = 0 );
```
### 5.3 Changes to `FontTrueType::loadFromStream`
**Current** (fonttruetype.cpp:427):
```cpp
if ( FT_Open_Face( static_cast<FT_Library>( mLibrary ), &args, 0, &face ) != 0 ) {
```
**Modified** add `faceIndex` parameter:
```cpp
bool loadFromStream( IOStream& stream, Uint32 faceIndex = 0 );
```
### 5.4 Changes to `FontTrueType::loadFromPack`
Add `faceIndex` passthrough:
```cpp
bool loadFromPack( Pack* pack, std::string filePackPath, Uint32 faceIndex = 0 );
```
### 5.5 Changes to `FontTrueType` Static Factory
Add a new constructor variant:
```cpp
static FontTrueType* New( const std::string& FontName, const std::string& filename,
Uint32 faceIndex );
```
### 5.6 New Member: `mFaceIndex`
```cpp
// In fonttruetype.hpp, protected section:
Uint32 mFaceIndex{ 0 }; ///< Face index for .ttc TrueType Collections
```
Stored on construction and passed through to FreeType calls. Since all current callers use `faceIndex = 0`, this is a purely additive, non-breaking change.
---
## 6. Fallback Glyph Routing Strategy
### 6.1 Current Fallback Chain in `FontTrueType::getGlyph`
The existing fallback chain (fonttruetype.cpp:549-619) is:
1. Emoji fallback (color emoji font emoji font)
2. BoldItalic variant
3. Bold variant
4. Italic variant
5. Own glyph index lookup
6. FontManager fallback fonts (if `mEnableFallbackFont`)
### 6.2 Proposed: Add OS-Level Fallback as Step 7
After step 6 fails (no fallback font from FontManager contains the glyph), add:
```cpp
// Step 7: Ask the OS for a font containing this codepoint
if ( 0 == idx && mEnableFallbackFont && mEnableSystemFallback ) {
FontDesc fallbackDesc = SystemFontResolver::instance()->getFallbackForCodepoint(
codePoint, FontWeight::Normal, false );
if ( !fallbackDesc.path.empty() ) {
FontTrueType* systemFallback = getOrLoadSystemFallbackFont( fallbackDesc );
if ( systemFallback && ( idx = systemFallback->getGlyphIndex( codePoint ) ) ) {
if ( mIsMonospace && mEnableDynamicMonospace ) {
mIsMonospaceComplete = false;
mUsingFallback = true;
}
return systemFallback->getGlyphByIndex( idx, characterSize, bold, italic,
outlineThickness, getPage( characterSize ) );
}
}
}
```
### 6.3 New Member: `mEnableSystemFallback`
```cpp
// In fonttruetype.hpp:
bool mEnableSystemFallback{ true };
public:
bool isSystemFallbackEnabled() const;
void setEnableSystemFallback( bool enableSystemFallback );
```
### 6.4 System Fallback Font Cache in FontManager
To avoid repeatedly loading the same system font file for every codepoint miss, `FontManager` maintains a small LRU cache of loaded system fallback fonts:
```cpp
// In fontmanager.hpp, protected section:
std::vector<std::unique_ptr<FontTrueType>> mSystemFallbackFonts;
static constexpr Uint32 MAX_SYSTEM_FALLBACK_FONTS = 8;
public:
FontTrueType* getOrLoadSystemFallbackFont( const FontDesc& desc );
```
**Flow:**
1. `FontManager::getOrLoadSystemFallbackFont()` checks if a `FontTrueType*` for the path+faceIndex already exists in `mSystemFallbackFonts`.
2. If yes, returns cached pointer.
3. If no, calls `FontTrueType::New(family, desc.path, desc.faceIndex)`, adds to cache. If cache exceeds `MAX_SYSTEM_FALLBACK_FONTS`, evicts LRU.
### 6.5 Asynchronous Codepoint Fallback Prefetch
For codepoints that the `getFallbackForCodepoint()` cache misses on, we have two options:
**Option A (Recommended): Synchronous with fast path.** On first miss for a codepoint, `getFallbackForCodepoint()` scans the already-enumerated `mFontList` in-memory (no OS calls), checks `fontContainsCodepoint()` with FreeType's `FT_New_Face` + `FT_Get_Char_Index` + `FT_Done_Face`. Since `FT_New_Face` for a single font file is ~0.5-2ms and we may need to scan ~20 fonts to find a match, total cost for first miss is ~10-40ms. This is acceptable because:
- It only happens once per unique missing codepoint (result is cached)
- It happens outside the render loop (during layout/initialization)
- For CJK text, common hanzi/kanji will be in the first few system CJK fonts
**Option B (Future optimization): Deferred.** On cache miss, enqueue a background task, return empty. Font will appear on next frame after load completes.
**We implement Option A for initial release.**
### 6.6 `fontContainsCodepoint()` Implementation Strategy
```cpp
bool SystemFontResolver::fontContainsCodepoint( const std::string& path, Uint32 codepoint,
Uint32 faceIndex ) {
// Use FreeType to quickly check without creating a full FontTrueType
FT_Library ftLib;
if ( FT_Init_FreeType( &ftLib ) != 0 )
return false;
FT_Face face;
if ( FT_New_Face( ftLib, path.c_str(), static_cast<FT_Long>( faceIndex ), &face ) != 0 ) {
FT_Done_FreeType( ftLib );
return false;
}
bool hasGlyph = FT_Get_Char_Index( face, codepoint ) != 0;
FT_Done_Face( face );
FT_Done_FreeType( ftLib );
return hasGlyph;
}
```
**Optimization:** Use a per-thread FreeType library to avoid `FT_Init_FreeType`/`FT_Done_FreeType` overhead. Store in thread-local storage.
### 6.7 Integration Point: `UISceneNode::getFontFromNamesList`
Modify the cascade so that after `FontManager::getByName()` fails for all names in the list, `SystemFontResolver::resolveFromNamesList()` is called:
```cpp
Font* UISceneNode::getFontFromNamesList( std::string_view names ) const {
Font* font = nullptr;
String::readBySeparatorStoppable(
names,
[&font]( std::string_view name ) {
name = String::trim( name, ' ' );
name = String::trim( name, '\'' );
font = FontManager::instance()->getByName( std::string{ name } );
return font == nullptr;
},
',' );
// NEW: System font fallback
if ( !font && SystemFontResolver::existsSingleton() ) {
FontDesc desc = SystemFontResolver::instance()->resolveFromNamesList(
std::string{ names }, FontWeight::Normal, false );
if ( !desc.path.empty() ) {
// Load via FontFamily or FontTrueType::New
FontTrueType* ttf = FontTrueType::New( desc.path, desc.path, desc.faceIndex );
if ( ttf && ttf->loaded() )
font = ttf;
}
}
return font;
}
```
---
## 7. Changes to CSS Shorthand Parser
### 7.1 `stylesheetspecification.cpp` — System Font Keywords
**Current state** (line 1221-1226): Six CSS system font keywords (`caption`, `icon`, `menu`, `message-box`, `small-caption`, `status-bar`) are detected but return empty.
**Fix:** When a system font keyword is detected, resolve it via `SystemFontResolver` and return the appropriate sub-properties:
```cpp
static const UnorderedMap<std::string, GenericFamily> systemFontToGeneric = {
{ "caption", GenericFamily::SystemUi },
{ "icon", GenericFamily::SystemUi },
{ "menu", GenericFamily::SystemUi },
{ "message-box", GenericFamily::SystemUi },
{ "small-caption",GenericFamily::SystemUi },
{ "status-bar", GenericFamily::SystemUi },
};
for ( const auto& sysFont : systemFontToGeneric ) {
if ( lowerVal == sysFont.first ) {
if ( SystemFontResolver::existsSingleton() ) {
FontDesc desc = SystemFontResolver::instance()->resolveGeneric(
sysFont.second, FontWeight::Normal, false );
// Build StyleSheetProperty vector with resolved family and metadata
return {
StyleSheetProperty( "font-family", desc.path ),
// ... font-style, font-size from system font metadata
};
}
return {}; // No resolver → no system fonts available
}
}
```
---
## 8. Generic Font Family Resolution
CSS defines five generic font families: `serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`. Plus `system-ui`, `emoji`, `math`, `fangsong` (CSS Fonts Level 4).
### 8.1 Default Mappings Per Platform
| Generic | Windows | macOS | Linux (Fontconfig) | Android | Haiku |
|---------|---------|-------|---------------------|---------|-------|
| `serif` | "Times New Roman" | "Times" | `FC_SERIF` "DejaVu Serif" or "Liberation Serif" | "Noto Serif" | "Noto Serif" |
| `sans-serif` | "Arial" | "Helvetica" | `FC_SANS` "DejaVu Sans" or "Liberation Sans" | "Roboto" | "Noto Sans" |
| `monospace` | "Consolas" | "Menlo" | `FC_MONO` "DejaVu Sans Mono" or "Liberation Mono" | "Droid Sans Mono" | "Noto Sans Mono" |
| `cursive` | "Comic Sans MS" | "Apple Chancery" | `FC_SANS` (fallback) | "Dancing Script" | "Noto Sans" |
| `fantasy` | "Impact" | "Papyrus" | `FC_SANS` (fallback) | "Noto Sans" | "Noto Sans" |
| `system-ui` | "Segoe UI" | "SF Pro" | Fontconfig default | "Roboto" | "Noto Sans" |
| `emoji` | "Segoe UI Emoji" | "Apple Color Emoji" | "Noto Color Emoji" | "Noto Color Emoji" | "Noto Color Emoji" |
These are resolved in `resolveGeneric()` via `mGenericFallbacks`, populated per-platform in `populateGenericFallbacks()`.
### 8.2 Integration into `resolve()`
When `resolve()` is called with a family name, the matching algorithm:
1. Check `mResolveCache` (hash hit)
2. Search `mFontList` for exact family match
3. If family is a known CSS generic keyword call `resolveGeneric()`
4. If no match found try nearest family in `mFontList` (Levenshtein/prefix match)
5. Return empty `FontDesc` if nothing found
---
## 9. Thread Safety
| Structure | Access Pattern | Thread Safety |
|-----------|---------------|---------------|
| `mFontList` | Write-once on populate, read-only after | Mutex-guarded populate, lockless reads after flag set |
| `mResolveCache` | Lazy insert, concurrent reads | Fine-grained mutex per insert; reads are lockless (insertions are atomic from reader POV since we store plain structs) |
| `mCodepointFallbackCache` | Lazy insert, concurrent reads | Same as above |
| `mGenericCache` | Populated at startup, read-only after | No synchronization needed after init |
| `fontContainsCodepoint()` | Creates/destroys FT_Library per call | Must not be called concurrently for same library. Per-call FT_Library init/done is safe. |
**FreeType Library Sharing:** For `fontContainsCodepoint()`, each call creates a fresh `FT_Library`, opens the face, checks, and cleans up. This is threadsafe (FreeType 2.10+ is threadsafe when using separate libraries per thread). For optimization, a per-thread `FT_Library` thread-local can be used.
---
## 10. Implementation Order
### Phase 1: Core Interface + Stub
1. Create `include/eepp/graphics/systemfontresolver.hpp` full public interface
2. Create `src/eepp/graphics/systemfontresolver.cpp` common implementation (enumeration caching, matching/scoring, generic fallback lookup, `resolveFromNamesList`, `fontContainsCodepoint`)
3. Create no-op stubs for all five platform files (return empty lists)
4. Integrate into `premake4.lua` / build system
5. Compile and verify
### Phase 2: Platform Implementations
6. Linux/BSD Fontconfig (`systemfontresolver_linux.cpp`) test
7. Windows DirectWrite (`systemfontresolver_win.cpp`) test on Windows
8. macOS/iOS Core Text (`systemfontresolver_macos.cpp`) test on macOS
9. Android NDK API 29+ + XML fallback (`systemfontresolver_android.cpp`)
10. Haiku BFont (`systemfontresolver_haiku.cpp`)
### Phase 3: FreeType faceIndex Integration
11. Add `faceIndex` parameter to all `loadFrom*` methods (default 0, backward compatible)
12. Add `mFaceIndex` member to `FontTrueType`
13. Add `FontTrueType::New(family, path, faceIndex)` factory
### Phase 4: Fallback Glyph Routing
14. Add `mEnableSystemFallback` flag to `FontTrueType`
15. Implement `FontManager::getOrLoadSystemFallbackFont()`
16. Add OS fallback step to `FontTrueType::getGlyph()` and `getGlyphDrawable()`
17. Add codepoint fallback cache + `fontContainsCodepoint()`
### Phase 5: CSS Integration
18. Fix system font keywords in `stylesheetspecification.cpp` font shorthand parser
19. Add generic font family resolution to `getFontFromNamesList` in `uiscenenode.cpp`
20. Wire `SystemFontResolver::resolveFromNamesList()` into `getFontFromNamesList`
### Phase 6: Testing & Validation
21. Write unit tests for `SystemFontResolver` (mockable with known font fixtures)
22. Run existing font rendering tests to verify no regressions
23. Test on all platforms
---
## 11. Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| FreeType init/done overhead in `fontContainsCodepoint()` | MEDIUM | Per-thread `FT_Library` cache in thread-local; amortized across codepoint batches |
| Fontconfig being slow on first query | MEDIUM | Lazy populate; `FcFontList()` is the unavoidable first-hit cost (~60ms) |
| Android XML parsing for legacy fallback | LOW | Only on API < 29; happens once, cached |
| TTC face index mismatches | LOW | Each platform API provides the face index natively (Fontconfig: `FC_INDEX`, DirectWrite: `IDWriteFontFace::GetIndex`, Core Text: implicit) |
| Breaking existing loadFromFile callers | NONE | All `faceIndex` parameters default to `0` |
| Memory from cached system fallback fonts | LOW | `MAX_SYSTEM_FALLBACK_FONTS = 8`, capped |
| Emscripten platform | LOW | No system fonts available; resolver returns empty; CSS relies on `@font-face` or bundled fonts only |
---
## 12. Verification
### Unit Tests
- `SystemFontResolver.enumerate` verify `mFontList` is non-empty on all desktop platforms
- `SystemFontResolver.resolve_ExactFamily` query "Arial" verify path exists
- `SystemFontResolver.resolve_GenericFamily` query "sans-serif" verify valid path
- `SystemFontResolver.resolveFromNamesList` "NonExistent, sans-serif" returns sans-serif path
- `SystemFontResolver.fontContainsCodepoint` ASCII codepoints in known fonts
- `SystemFontResolver.getFallbackForCodepoint` CJK codepoint (0x65E5) valid CJK font path
### Integration Tests
- `FontTrueType.loadFromTTC` load from a .ttc file with faceIndex > 0
- `UISceneNode.getFontFromNamesList_SystemFallback` — CSS font-family with only system font names
- `FontManager.systemFallbackFonts` — verify LRU eviction behavior
### Existing Test Regression
- All font rendering tests must pass unchanged (`FontRendering.*`, `Text.*`)
- All UI layout tests must pass (system fonts only activate on codepoint miss, which existing tests should not trigger)
---
## 13. Non-Scope (Future Extensions)
- **Font variations (variable fonts with weight/width/slant axes):** The `FontQuery`/`FontDesc` structs can be extended later with axis coordinates.
- **Background font enumeration (async):** For very large font collections (1000+ fonts), `populateFontList()` could be moved to a background thread.
- **Font installation monitoring:** Detecting OS font install/uninstall at runtime and invalidating caches.
- **Emoji font discovery:** Currently hard-coded; could leverage `SystemFontResolver` to auto-detect emoji fonts.
- **PDF/print font embedding:** Using resolved file paths to embed fonts in generated PDFs.

View File

@@ -0,0 +1,943 @@
# UI Inline Formatting Context Plan
## Goal
Introduce a UI-specialized inline formatting path that can lay out HTML/UI inline
content directly from the existing `Node` / `UIWidget` / `UITextNode` tree,
without first reconstructing a parallel `Graphics::RichText::InlineItem` tree.
The current first-class inline boxes implementation is functionally correct and
must remain the baseline. This plan is about reducing duplicated data, improving
hot-path allocation behavior, and separating the generic `Graphics::RichText`
frontend from the browser-oriented UI frontend.
The desired end state is:
- `Graphics::RichText` remains a generic graphics primitive.
- The current `RichText::InlineItem` path remains valid for non-UI callers.
- UI/HTML inline layout can consume the existing UI node tree directly.
- Shared inline layout logic is reused instead of duplicating line-breaking,
baseline, float, selection, and fragment behavior.
- UI fragments reference source UI nodes and drawables instead of copying large
style/widget state into a mirrored RichText tree.
- The migration is incremental, with the existing RichText path available as the
verified fallback until each UI phase is proven.
## Current Problem
The current UI pipeline is:
1. `UIRichText::rebuildRichText()` walks the UI child tree.
2. It builds a parallel `Graphics::RichText::mInlineItems` tree.
3. `RichTextInlineLayouter` flattens that tree into layout runs.
4. Layout produces `RenderParagraph`, `RenderSpan`, and `InlineFragment` data.
5. `BlockLayouter::positionRichTextChildren()` groups fragments by source node
and maps geometry back to UI widgets/text nodes.
6. Drawing uses `RenderSpan` for text/atomic payloads and `InlineFragment` for
inline box backgrounds, borders, decorations, and hit boxes.
This is acceptable for a generic rich text widget. For browser-like HTML it is
less attractive because:
- The UI node tree already exists and already stores most CSS/widget state.
- The RichText inline tree duplicates source hierarchy.
- Several fields are copied or bridged from UI to Graphics only to be mapped
back to UI later.
- Some data is carried through several structures:
- `UIWidget` / `UITextNode`
- `RichText::InlineItem`
- `InlineLayoutRun`
- `RenderSpan`
- `InlineFragment`
- `BlockLayouter::FragmentBucket`
- Background/border fidelity has required more bridge fields, for example
drawable pointers and fragment-color override flags.
- Allocation pressure remains visible in vectors, text objects, fragment lists,
and repeated temporary structures.
## Non-Goals
- Do not make `Graphics::RichText` depend on UI types.
- Do not remove `Graphics::RichText::InlineItem` in this project phase.
- Do not rewrite the whole UI layout engine in one pass.
- Do not regress drawing, selection string, hit testing, selection rectangles,
inline background/border painting, line-height, vertical-align, floats, tables,
details/summary, forms, or invalid-width performance.
- Do not reintroduce legacy `RichText::Block`, `SpanBlock`, `CustomBlock`,
`getBlocks()`, or `mBlocks`.
- Do not add compatibility layers for deleted RichText block APIs.
## Architectural Direction
Split the inline formatting engine into two conceptual layers:
1. A storage-agnostic inline layout core.
2. One or more frontends/providers that expose inline items to that core.
The initial frontends should be:
- `GraphicsRichTextInlineProvider`
- Reads `Graphics::RichText::mInlineItems`.
- Preserves current generic RichText behavior.
- `UIInlineProvider`
- Walks `Node` / `UIWidget` / `UITextNode` children directly.
- Resolves CSS/UI metrics from existing widgets.
- Produces UI-oriented fragments that reference source nodes.
The layout core should not know whether items came from `RichText::InlineItem` or
the UI tree. It should operate on a compact item view/cursor API.
## Proposed Types
Exact names can change, but ownership boundaries should remain.
### Inline Item View
The shared layout core needs a lightweight, non-owning view of the current inline
item.
Conceptual shape:
```cpp
struct InlineItemView {
enum class Type { TextRun, BoxStart, BoxEnd, AtomicBox, LineBreak };
Type type;
InlineSourceId source;
InlineStyleId style;
InlineBoxMetrics box;
InlineTextRun text;
InlineAtomicMetrics atomic;
InlineFloat floatType;
InlineClear clearType;
BaselineAlignValue baselineAlign;
};
```
Important requirements:
- The view must not own strings, widgets, text objects, or child vectors.
- The view can be invalidated by advancing the provider cursor.
- The layout core may copy only the small fields needed for output fragments.
- Source identity must be stable enough to map layout results back to nodes.
### Inline Provider
The provider exposes the inline content stream in tree order.
Conceptual API:
```cpp
class InlineProvider {
public:
void reset();
bool next( InlineItemView& out );
FontStyleConfig resolveTextStyle( InlineStyleId style ) const;
String::View text( const InlineTextRun& run ) const;
Sizef atomicSize( const InlineAtomicMetrics& atomic ) const;
Float atomicBaseline( const InlineAtomicMetrics& atomic ) const;
};
```
The API may be implemented as templates instead of virtual calls if profiling
shows virtual dispatch overhead matters. Start with the simplest design that
keeps the storage boundary clean.
### UI Source Identity
Avoid `void*` proliferation in new UI-specific structures.
Use a small typed source handle:
```cpp
struct UISourceRef {
enum class Type { None, TextNode, Widget };
Type type{ Type::None };
Node* node{ nullptr };
};
```
The generic `Graphics::RichText` path can keep `InlineSource` as it exists today.
The UI path can use typed UI source refs internally.
### Layout Output
The shared core should produce storage-neutral line results, but the UI path
should avoid copying full widget metadata into fragments.
Conceptual output:
```cpp
struct InlineLineBox {
Float y;
Float height;
Float baseline;
Float width;
SmallVector<InlineFragmentRef, 16> fragments;
};
struct InlineFragmentRef {
enum class Type { TextRun, Box, AtomicBox };
Type type;
UISourceRef source;
Rectf bounds;
Rectf paintBounds;
Int64 startCharIndex;
Int64 endCharIndex;
bool startsInlineBox;
bool endsInlineBox;
InlineFragmentPaint paint;
};
```
`InlineFragmentPaint` should be compact. For the UI path, prefer references to
existing UI drawables/styles rather than copied values.
## UI-Specific Implementation Strategy
### 1. Keep The Current RichText Path As Baseline
Before introducing the UI-specific path:
- Preserve the current `UIRichText::rebuildRichText()` path.
- Keep all current tests passing.
- Add any missing regression coverage for behavior discovered during the review:
- background color plus border radius on inline anchors,
- background images on inline boxes,
- split inline background/border fragments,
- nested inline box hit boxes.
The first UI-specific implementation should be hidden behind a feature flag or
internal switch so test comparisons can run both paths.
Possible flag:
```cpp
enum class InlineLayoutBackend {
GraphicsRichText,
UINodeTree
};
```
Default must remain the current backend until the UI path reaches parity.
### 2. Extract Layout Core From `RichTextInlineLayouter`
Move the reusable algorithms out of the private implementation that directly
depends on `std::vector<RichText::InlineItem>`.
Candidate responsibilities to extract:
- text run measurement,
- text wrapping,
- line construction,
- float placement,
- baseline alignment,
- line metric recomputation,
- fragment reconstruction,
- first/last inline box edge detection,
- selection/hit-test geometry helpers.
Do not extract everything at once. Start with a low-risk seam:
1. Build layout runs from a provider.
2. Keep the rest of `RichTextInlineLayouter` unchanged.
3. Prove that the RichText provider produces byte-for-byte equivalent output for
current tests.
### 3. Introduce `GraphicsRichTextInlineProvider`
This provider adapts existing `RichText::InlineItem` data to the new provider
API.
Acceptance criteria:
- No behavior changes.
- `RichText.*` passes.
- `UIRichText.*` passes through the existing RichText backend.
- No new UI includes in `Graphics::RichText`.
This phase proves the provider abstraction without changing UI behavior.
### 4. Introduce `UIInlineProvider`
The UI provider should walk the actual children of the `UIRichText` container.
Responsibilities:
- Traverse text nodes and inline widgets in tree order.
- Apply the same whitespace collapsing rules as current
`UIRichText::rebuildRichText()`.
- Represent true inline widgets as box start/end items.
- Represent inline-blocks, replaced elements, floats, controls, list markers,
and line breaks as atomic items.
- Skip invisible nodes.
- Skip out-of-flow descendants where current layout does.
- Resolve margins, padding, borders, background, text decoration, line-height,
baseline alignment, float, and clear from existing `UIWidget` state.
- Keep source references as `UITextNode*` / `UIWidget*`.
Important: the UI provider must not allocate a mirror tree.
The provider may keep a traversal stack. That stack should use `SmallVector` and
contain only node pointers and small state:
```cpp
struct UITraversalFrame {
Node* node;
Node* nextChild;
UIWidget* inlineBox;
bool emittedStart;
};
```
### 5. Shared Whitespace Collapsing
Whitespace collapsing is currently embedded in `UIRichText::rebuildRichText()`.
The UI provider needs equivalent behavior without materializing a copied text
tree.
Plan:
- Extract whitespace state into a small helper:
```cpp
struct InlineWhitespaceState {
bool shouldCollapse;
bool lastRunEndsWithSpace;
bool atBlockBoundary;
};
```
- For each text node, produce a `String::View` or a compact transformed buffer.
- Avoid allocating a new `String` when no trimming/collapse is needed.
- If trimming is needed, prefer range slicing over copying.
- If internal whitespace normalization is needed, use a reusable scratch buffer
owned by the provider or layout context.
Acceptance tests:
- Existing whitespace tests stay green:
- `UIRichText.WhitespaceCollapseTest`
- `UIRichText.WhitespaceCollapseCodeTest`
- `UIRichText.WhitespaceCollapseBRTest`
- `UITextNode_Regression.WhitespaceCollapseDoesNotCreateSpuriousNodes`
### 6. UI Text Measurement Without Per-Run `Text` Allocation
The current path creates `Text` objects for inline runs and render spans. The UI
path should eventually avoid that for layout measurement.
Incremental strategy:
1. Keep existing `Text` drawing for final rendering.
2. Introduce a measurement helper that can compute:
- width,
- wraps,
- min intrinsic width,
- max intrinsic width,
- line height,
- baseline,
from `String::View + FontStyleConfig`.
3. Cache shaped/wrapped results by source text node generation, style generation,
max width, and shaper settings.
4. Only materialize `Text` or shaped draw payloads for final draw fragments.
Do not start with a large text/shaper rewrite. First remove repeated allocation
from obvious layout-only paths.
### 7. UI Fragment Mapping
Once the UI provider exists, `BlockLayouter::positionRichTextChildren()` should
consume UI fragments directly.
Current `BlockLayouter` groups `RichText::InlineFragment`s by source pointer.
The UI path can avoid the map when possible:
- Fragments already contain typed `UISourceRef`.
- During fragment generation, append fragment pointers/ranges directly to a
per-node layout cache.
- Each `UITextNode` / `UITextSpan` can receive hit boxes from its source
fragments without a separate `UnorderedMap` pass.
Potential structures:
```cpp
struct UIInlineLayoutResult {
SmallVector<InlineLineBox, 8> lines;
SmallVector<UIInlineFragment, 64> fragments;
UnorderedMap<Node*, FragmentRange> sourceRanges; // transitional only
};
```
Longer term, avoid `sourceRanges` by storing fragment ranges on the relevant UI
nodes during layout, or by preserving fragment order and resolving during the
same traversal.
### 8. Drawing
The UI-specific draw path should draw from UI fragments and source widgets.
Rules:
- Text fragments draw text using source text/style data.
- Inline box backgrounds should use the source widget background drawable when
it has real background data or radius.
- Font background color and widget background radius must compose correctly.
- Border drawables should come from the source widget.
- Split inline boxes must respect `startsInlineBox` and `endsInlineBox`.
- If the current drawable APIs cannot suppress continuation sides cleanly, add a
drawable-level side mask/clipping API rather than duplicating border painting
logic in RichText.
Suggested follow-up API:
```cpp
enum class BoxSideMask : Uint8 {
None = 0,
Left = 1 << 0,
Top = 1 << 1,
Right = 1 << 2,
Bottom = 1 << 3,
All = Left | Top | Right | Bottom
};
struct DrawableBoxPaintOptions {
BoxSideMask sides{ BoxSideMask::All };
const Color* colorOverride{ nullptr };
};
```
Then add an overload for UI drawables that need it:
```cpp
void draw( const Vector2f& position, const Sizef& size,
const DrawableBoxPaintOptions& options );
```
Do not add this until the current fragment behavior is covered by tests.
## Current RichText Optimization Plan
These optimizations can be done even before the UI-specialized backend exists.
### A. Reuse Persistent Output Storage
Current public/internal storage:
- `std::vector<InlineItem> mInlineItems`
- `std::vector<InlineFragment> mInlineFragments`
- `std::vector<RenderParagraph> mLines`
- `std::vector<RenderSpan> RenderParagraph::spans`
- `std::vector<InlineItem> InlineItem::Box::children`
Do not blindly replace all of these with `SmallVector`.
Recommended changes:
- Keep top-level `mInlineItems`, `mInlineFragments`, and `mLines` as
`std::vector` unless profiling shows most documents are tiny. These can grow
large in HTML.
- Preserve capacity across rebuilds. Prefer `clear()` and refill over assigning
a temporary vector.
- Change `RichTextInlineLayouter::rebuildFragments()` to fill an output vector
passed by reference:
```cpp
static void rebuildFragments( const InlineItems& items,
const Lines& lines,
std::vector<InlineFragment>& out );
```
- Avoid `mInlineFragments = rebuildFragments(...)` because it may discard useful
capacity or force extra moves.
- Consider `SmallVector<RenderSpan, 8>` for `RenderParagraph::spans` only after
checking object size and typical spans-per-line.
### B. Reduce `Text` Object Allocation
High-priority allocation source:
- `RichText::addInlineText()` allocates `std::shared_ptr<Text>`.
- `appendTextRenderSpan()` creates render text payloads for substrings.
Incremental plan:
1. Add a `RenderTextRun` payload that can reference:
- source `Text*`, or
- source string view/range plus `FontStyleConfig`.
2. Keep existing `Text` draw path initially.
3. Reuse `Text` objects from a per-`RichText` pool for render spans.
4. Reset and refill pooled `Text` objects during layout.
5. Later, replace pooled `Text` objects with a lighter shaped-text fragment if
Text supports enough low-level drawing hooks.
Acceptance criteria:
- `RichText.RichTextTest` remains green with shaper disabled/enabled/enabled
without optimizations.
- `FontRendering.TextBackgroundColor` remains green.
- Selection color application still works.
### C. Cache Ancestor Metadata
Current hot pattern:
- paths are stored as `SmallVector<size_t, 4>`,
- helpers repeatedly resolve inline ancestor boxes,
- first/last leaf checks can recursively scan children.
Optimization:
- During layout-run construction, compute an `InlineAncestorChain` once.
- Store cached values on `InlineLayoutRun`:
- effective baseline alignment,
- inherited text decoration,
- start spacing,
- end spacing,
- first/last leaf flags for each ancestor edge,
- line-height edge contribution.
This should reduce repeated calls to:
- `resolveInlineBox()`
- `inlineAncestorStartSpacing()`
- `inlineAncestorEndSpacing()`
- `inlineAncestorTextDecoration()`
- `isFirstInlineLeafInBox()`
- `isLastInlineLeafInBox()`
### D. Flatten Internal Inline Storage
For generic `Graphics::RichText`, consider replacing nested child vectors with a
flat arena.
Current:
```cpp
struct Box {
std::vector<InlineItem> children;
};
```
Potential:
```cpp
struct InlineItem {
InlineItemKind kind;
size_t firstChild;
size_t childCount;
size_t parent;
Payload payload;
};
std::vector<InlineItem> mInlineItems;
```
Benefits:
- fewer per-box heap allocations,
- better traversal locality,
- parent access without path resolution,
- child ranges instead of recursive vector ownership,
- easier fragment source indexing.
Costs:
- builder API becomes more complex,
- moving/erasing items is harder,
- tests and helper functions need significant updates.
Recommendation:
- Do not start here.
- First extract provider/layout seams.
- If we keep optimizing generic `RichText`, flat arena is the bigger structural
optimization after storage reuse and text allocation reduction.
### E. Avoid Hot-Path Hash Maps Where Possible
Current state:
- `BlockLayouter` owns reusable `UnorderedMap<void*, FragmentBucket>` maps.
- Bucket lists use `SmallVector`.
Next improvements:
- For the UI-specialized backend, avoid grouping by pointer after layout.
- Emit source fragment ranges during fragment generation.
- If maps remain needed, consider:
- keeping them persistent and capacity-stable,
- using typed keys (`Node*`) instead of `void*`,
- clearing buckets without freeing bucket vectors,
- avoiding `operator[]` when lookup-only behavior is intended.
## Migration Phases
Every phase has a hard completion gate:
- Build and all required focused tests for that phase must pass.
- The full unit test suite must pass unless the phase explicitly says it is
documentation-only and has no code changes.
- `git diff --check` must pass.
- A recovery checkpoint must be saved with `git stash`.
- The checkpoint must be re-applied immediately so the working tree keeps the
completed phase changes.
Checkpoint workflow:
```bash
git stash push -u -m "ui-inline-formatting-context phase N: <short description>"
git stash apply stash@{0}
```
Do not use `git stash pop` for checkpoints. The stash entry must remain in the
stash list as the last known-good recoverable snapshot. If a later phase fails or
becomes hard to unwind, recover from the latest passing phase checkpoint instead
of manually reconstructing the working tree.
Each phase's final notes must record:
- validation commands run,
- pass/fail counts,
- any known flaky reruns, especially Xvfb cookie failures,
- stash checkpoint message,
- stash reference if available.
### Phase 0: Baseline And Metrics
Purpose: establish behavior and performance numbers before refactoring.
Tasks:
- Record current focused test timings:
- `UIRichText.InvalidWidthLengthComputation3`
- `UIRichText.*`
- `UIBackground.*`
- `UIHTMLTable.complexLayout*`
- `UIHTMLFloat.*`
- Add a debug allocation counter or targeted instrumentation if available.
- Document the number of:
- inline items,
- render spans,
- inline fragments,
- text objects,
for representative HTML examples.
Representative examples:
- `bin/unit_tests/assets/html/background_positioning.html`
- inline anchor wrapping case,
- inline-block browser test,
- details/summary fixture,
- table complex layout fixture.
Exit criteria:
- Baseline numbers are recorded in the agent loop or this plan.
- No code behavior changes.
- Required validation passes.
- Stash checkpoint is created and re-applied, even if the phase only records
baseline documentation. This preserves a named restore point before code
refactoring starts.
### Phase 1: Extract Shared Layout Run Provider
Purpose: introduce the provider seam while preserving current behavior.
Tasks:
- Define provider/item-view types in a Graphics-safe location.
- Implement `GraphicsRichTextInlineProvider`.
- Convert `buildLayoutRuns()` to consume the provider.
- Keep downstream layout code unchanged as much as possible.
Validation:
- `RichText.*`
- `UIRichText.*`
- `UIHTMLFloat.*`
- `UIHTMLTable.complexLayout*`
- full suite
- `git diff --check`
Exit criteria:
- Output behavior is unchanged.
- Current RichText backend remains default.
- Required validation passes.
- Stash checkpoint is created and re-applied.
### Phase 2: Extract Fragment Builder Provider Hooks
Purpose: make fragment reconstruction independent of `RichText::InlineItem`.
Tasks:
- Move leaf collection and box accumulator logic behind provider/source queries.
- Keep `RichText::InlineFragment` output for the Graphics backend.
- Introduce parallel UI fragment types only if needed.
Validation:
- Selection rect tests.
- Hit testing tests.
- Wrapped inline border/background tests.
- Text decoration propagation tests.
Exit criteria:
- RichText backend still behaves exactly as before.
- Fragment construction no longer requires direct recursive access to
`std::vector<RichText::InlineItem>`.
- Required validation passes.
- Stash checkpoint is created and re-applied.
### Phase 3: Build Experimental UI Provider
Purpose: walk the UI node tree directly.
Tasks:
- Add `UIInlineProvider`.
- Implement traversal for:
- direct text nodes,
- nested inline spans,
- inline-block widgets,
- floats,
- `<br>`,
- invisible and out-of-flow nodes.
- Port whitespace collapsing into provider state.
- Add a switch to run one `UIRichText` instance through the experimental backend.
Validation:
- Compare layout results between RichText backend and UI backend for selected
tests.
- Initially compare:
- total character count,
- selection string,
- line count,
- text node bounds,
- widget hit boxes,
- inline fragment bounds.
Exit criteria:
- Experimental backend can pass a small focused subset without becoming default.
- Existing default backend remains green.
- Required validation passes.
- Stash checkpoint is created and re-applied.
### Phase 4: UI Layout Result And Mapping
Purpose: remove the map-back step for the UI backend.
Tasks:
- Add `UIInlineLayoutResult`.
- Store source refs as typed `Node*` / `UIWidget*` / `UITextNode*`.
- Map text node bounds and widget hit boxes directly from source fragments.
- Keep `BlockLayouter` support for both current RichText fragments and new UI
fragments during transition.
Validation:
- `UITextNode_BlockLayouter.*`
- `UITextNode_RichTextRebuild.*`
- `UIRichText.selection`
- nested span over-find tests.
Exit criteria:
- UI backend does not need `RichText::InlineSource` or pointer grouping maps for
normal fragment mapping.
- Existing default backend remains green.
- Required validation passes.
- Stash checkpoint is created and re-applied.
### Phase 5: UI Drawing Backend
Purpose: draw inline UI fragments from source UI nodes and drawables.
Tasks:
- Draw inline backgrounds/borders from source widget drawables.
- Preserve font background color plus widget radius composition.
- Preserve split inline first/last edge flags.
- Keep text drawing consistent with current `RichText` behavior.
- Decide whether to add drawable side-mask API for perfect split border
continuation behavior.
Validation:
- `UIRichText.InlineParentFontBackgroundColorUsesBorderRadiusDrawable`
- `UIRichText.InlineParentBorderIsPreservedInFragments`
- `UIBackground.*`
- `UIBorder.*`
- image comparison tests if available.
Exit criteria:
- UI backend draws the same or better than current backend for covered cases.
- Existing default backend remains green.
- Required validation passes.
- Stash checkpoint is created and re-applied.
### Phase 6: Performance And Memory Cleanup
Purpose: remove the duplicated RichText inline tree from the UI backend.
Tasks:
- Stop calling `UIRichText::rebuildRichText()` for containers using UI backend.
- Keep generic `RichText` only as the public API for non-UI use.
- Remove transitional bridge fields from UI fragments when no longer needed.
- Keep current RichText bridge fields if generic RichText still needs them.
- Re-measure allocations and timings from Phase 0.
Exit criteria:
- UI backend is measurably better on memory allocations.
- No significant timing regression.
- Existing full suite passes.
- Required validation passes.
- Stash checkpoint is created and re-applied.
### Phase 7: Make UI Backend Default
Purpose: switch the production UI/HTML path.
Tasks:
- Flip default backend for `UIRichText`.
- Keep fallback switch temporarily for debugging.
- Update plans and comments that describe RichText reconstruction as the main UI
path.
- Remove fallback only after enough soak time.
Validation:
- Full suite.
- Targeted HTML/UI visual smoke tests.
- Any available screenshot/image-diff tests.
Exit criteria:
- UI backend is default.
- Generic `Graphics::RichText` remains green independently.
- Required validation passes.
- Stash checkpoint is created and re-applied.
## Test Matrix
Always run after broad changes:
```bash
make -C make/linux -j$(nproc)
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="RichText.*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="UIRichText.*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="UITextNode_RichTextRebuild.*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="UITextNode_BlockLayouter.*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="UIHTMLFloat.*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="UIHTMLTable.complexLayout*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug --filter="UIBackground.*"
ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug
git diff --check
```
If UI tests fail with:
```text
Invalid MIT-MAGIC-COOKIE-1 key
```
rerun the failed filter sequentially before investigating unrelated crashes.
## Checkpoint Policy
Each implementation phase must leave a recoverable stash snapshot. The checkpoint
is not a replacement for keeping the working tree active; it is a safety net.
Required sequence at the end of every passing phase:
1. Run the phase-specific focused tests.
2. Run the full suite unless the phase had no code changes.
3. Run `git diff --check`.
4. Create a checkpoint:
```bash
git stash push -u -m "ui-inline-formatting-context phase N: <short description>"
```
5. Re-apply it immediately:
```bash
git stash apply stash@{0}
```
6. Confirm the working tree still contains the phase changes.
7. Record the stash message in `.agent/plans/first_class_inline_boxes_agent_loop.md`
or a dedicated continuation log for this plan.
Important details:
- Use `git stash push -u` so new plan files, new tests, fixtures, and other
untracked artifacts are included in the checkpoint.
- Do not use `git stash pop`; popping destroys the checkpoint.
- Do not checkpoint a failing phase as if it were passing.
- If a phase needs partial experimental work that does not pass yet, stash it
separately with a clear `wip-failing` message before reverting or switching
direction. Do not call that stash a phase checkpoint.
- If the worktree already contains unrelated user changes, do not revert them.
Include them in the checkpoint only if they are part of the active phase or
unavoidable in the shared working tree; otherwise document the dirty files
before checkpointing.
## Acceptance Criteria
The plan is complete when:
- `Graphics::RichText` remains generic and independent of UI.
- UI/HTML inline layout can run without constructing a full
`RichText::InlineItem` mirror tree.
- Existing UI nodes remain the source of truth for widget style/drawable data.
- The UI backend preserves:
- drawing,
- background color,
- border radius,
- border drawing,
- background images,
- text decoration,
- selection string,
- selection rectangles,
- hit testing,
- text node bounds,
- inline widget hit boxes,
- float layout,
- inline-block layout,
- baseline and vertical-align behavior.
- Allocation counts and/or memory usage improve on representative HTML.
- Full unit suite passes.
## Risks
- Provider abstraction may become too generic and obscure the layout algorithm.
Keep item views concrete and small.
- UI provider traversal may accidentally diverge from current whitespace
behavior. Add tests before switching defaults.
- Text measurement without `Text` objects can drift from actual draw behavior.
Keep Text-backed rendering until measurement parity is proven.
- Fragment output can become fragmented across too many types. Avoid splitting
UI and Graphics outputs until a concrete dependency forces it.
- Drawable side masking may require deeper changes in border/background drawing.
Treat that as a follow-up fidelity feature unless tests require it.
## Recommended First Task
Start with Phase 0 and Phase 1 only.
Do not begin by writing the UI provider. First extract a provider seam from the
current `RichText::InlineItem` path and prove that the generic backend still
passes all tests. That gives us a stable extension point for the UI provider
without risking a broad rewrite.

View File

@@ -1,10 +1,11 @@
#ifndef EE_GRAPHICS_RICHTEXT_HPP
#define EE_GRAPHICS_RICHTEXT_HPP
#include <eepp/core/containers.hpp>
#include <eepp/graphics/drawable.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/ui/csslayouttypes.hpp>
#include <memory>
#include <utility>
#include <variant>
#include <vector>
@@ -18,6 +19,55 @@ namespace EE { namespace Graphics {
*/
class EE_API RichText : public Drawable {
public:
enum class InlineFloat { None, Left, Right };
enum class InlineClear { None, Left, Right, Both };
enum class BaselineAlignment {
Baseline,
Sub,
Super,
TextTop,
TextBottom,
Middle,
Top,
Bottom,
Length,
Percentage,
Auto
};
struct BaselineAlignValue {
BaselineAlignment type{ BaselineAlignment::Baseline };
Float value{ 0.f };
BaselineAlignValue( BaselineAlignment type = BaselineAlignment::Baseline,
Float value = 0.f ) :
type( type ), value( value ) {}
bool operator==( const BaselineAlignValue& other ) const {
return type == other.type && value == other.value;
}
bool operator!=( const BaselineAlignValue& other ) const { return !( *this == other ); }
};
enum class InlineSourceType { None, TextNode, Widget };
struct InlineSource {
InlineSourceType type{ InlineSourceType::None };
void* ptr{ nullptr };
InlineSource( InlineSourceType type = InlineSourceType::None, void* ptr = nullptr ) :
type( type ), ptr( ptr ) {}
bool operator==( const InlineSource& other ) const {
return type == other.type && ptr == other.ptr;
}
bool operator!=( const InlineSource& other ) const { return !( *this == other ); }
};
/** @return A new instance of RichText. */
static RichText* New();
@@ -36,7 +86,7 @@ class EE_API RichText : public Drawable {
void addSpan( const String& text, const FontStyleConfig& style, const Rectf& margin,
const Rectf& padding, Float lineHeight = 0,
const UI::CSSBaselineAlignValue& baselineAlign = {} );
const BaselineAlignValue& baselineAlign = {}, InlineSource source = {} );
/**
* @brief Adds a text span with individual style parameters.
@@ -80,27 +130,6 @@ class EE_API RichText : public Drawable {
/** @return The maximum intrinsic width of the text block. */
Float getMaxIntrinsicWidth();
enum class BlockType { Text, Drawable, CustomSize };
struct CustomBlock {
Sizef size;
UI::CSSFloat floatType{ UI::CSSFloat::None };
UI::CSSClear clearType{ UI::CSSClear::None };
Float baseline{ 0 };
bool isLineBreak{ false };
UI::CSSBaselineAlignValue baselineAlign;
};
struct SpanBlock {
std::shared_ptr<Text> text;
Rectf margin;
Rectf padding;
Float lineHeight{ 0 };
UI::CSSBaselineAlignValue baselineAlign;
};
using Block = std::variant<SpanBlock, std::shared_ptr<Drawable>, CustomBlock>;
/**
* @brief Adds a drawable (e.g., an image) into the text flow.
* @param drawable The drawable to add.
@@ -111,16 +140,13 @@ class EE_API RichText : public Drawable {
* @brief Adds a custom size spacer into the text flow.
* @param size The physical dimensions of the spacer.
*/
void addCustomSize( const Sizef& size, UI::CSSFloat floatType = UI::CSSFloat::None,
UI::CSSClear clearType = UI::CSSClear::None, Float baseline = -1.f,
const UI::CSSBaselineAlignValue& baselineAlign = {} );
void addCustomSize( const Sizef& size, InlineFloat floatType = InlineFloat::None,
InlineClear clearType = InlineClear::None, Float baseline = -1.f,
const BaselineAlignValue& baselineAlign = {}, InlineSource source = {} );
/** @brief Adds a virtual line break that is not associated with a DOM text character. */
void addLineBreak();
/** @return The list of blocks. */
const std::vector<Block>& getBlocks() { return mBlocks; }
virtual void draw( const Float& X, const Float& Y, const Vector2f& scale = Vector2f::One,
const Float& rotation = 0, BlendMode effect = BlendMode::Alpha(),
const OriginPoint& rotationCenter = OriginPoint::OriginCenter,
@@ -143,7 +169,23 @@ class EE_API RichText : public Drawable {
/** @brief Structure representing a rendered span within a line. */
struct RenderSpan {
Block block;
enum class Type { Text, Drawable, AtomicBox };
using InlinePath = SmallVector<size_t, 4>;
Type type{ Type::Text };
std::shared_ptr<Text> text;
std::shared_ptr<Drawable> drawable;
Rectf margin;
Rectf padding;
Float lineHeight{ 0 };
BaselineAlignValue baselineAlign;
bool suppressBackground{ false };
Float baseline{ 0 };
InlineFloat floatType{ InlineFloat::None };
InlineClear clearType{ InlineClear::None };
bool isLineBreak{ false };
InlinePath inlinePath;
Vector2f position; // Local position relative to RichText origin
Sizef size;
Int64 startCharIndex{ 0 };
@@ -215,8 +257,131 @@ class EE_API RichText : public Drawable {
/** Invalidates the current layout */
void invalidateLayout();
// ── Inline tree types (first-class inline boxes) ─────────────────────────
/** A single item in the inline formatting tree. */
struct InlineItem {
struct TextRun {
std::shared_ptr<Text> text;
InlineSource source;
Rectf margin;
Rectf padding;
Float lineHeight{ 0 };
BaselineAlignValue baselineAlign;
bool suppressBackground{ false };
};
struct Box {
InlineSource source;
Rectf margin;
Rectf padding;
Float lineHeight{ 0 };
BaselineAlignValue baselineAlign;
Color backgroundColor{ Color::Transparent };
Float borderWidth{ 0 };
Color borderColor{ Color::Transparent };
Drawable* backgroundDrawable{ nullptr };
Drawable* borderDrawable{ nullptr };
bool backgroundDrawableUsesFragmentColor{ false };
Uint32 textDecoration{ 0 };
bool participatesInLineMetrics{ true };
bool contributesInlineSpacing{ true };
std::vector<InlineItem> children;
};
struct AtomicBox {
InlineSource source;
std::shared_ptr<Drawable> drawable;
Sizef size;
Float baseline{ 0 };
InlineFloat floatType{ InlineFloat::None };
InlineClear clearType{ InlineClear::None };
bool isLineBreak{ false };
BaselineAlignValue baselineAlign;
};
std::variant<TextRun, Box, AtomicBox> data;
InlineItem() : data( TextRun{} ) {}
explicit InlineItem( TextRun run ) : data( std::move( run ) ) {}
explicit InlineItem( Box box ) : data( std::move( box ) ) {}
explicit InlineItem( AtomicBox box ) : data( std::move( box ) ) {}
TextRun& asTextRun() { return std::get<TextRun>( data ); }
const TextRun& asTextRun() const { return std::get<TextRun>( data ); }
Box& asBox() { return std::get<Box>( data ); }
const Box& asBox() const { return std::get<Box>( data ); }
AtomicBox& asAtomicBox() { return std::get<AtomicBox>( data ); }
const AtomicBox& asAtomicBox() const { return std::get<AtomicBox>( data ); }
bool isTextRun() const { return std::holds_alternative<TextRun>( data ); }
bool isBox() const { return std::holds_alternative<Box>( data ); }
bool isAtomicBox() const { return std::holds_alternative<AtomicBox>( data ); }
};
/** @return The inline item tree. */
const std::vector<InlineItem>& getInlineItems() const { return mInlineItems; }
/** A laid-out fragment produced by an inline item on one rendered line. */
struct InlineFragment {
enum class Type { TextRun, Box, AtomicBox };
Type type{ Type::TextRun };
RenderSpan::InlinePath itemPath;
size_t lineIndex{ 0 };
Rectf bounds;
Rectf paintBounds;
Int64 startCharIndex{ 0 };
Int64 endCharIndex{ 0 };
std::shared_ptr<Text> text;
BaselineAlignValue baselineAlign;
InlineSource source;
bool startsInlineBox{ false };
bool endsInlineBox{ false };
Color backgroundColor{ Color::Transparent };
Float borderWidth{ 0 };
Color borderColor{ Color::Transparent };
Drawable* backgroundDrawable{ nullptr };
Drawable* borderDrawable{ nullptr };
bool backgroundDrawableUsesFragmentColor{ false };
Uint32 textDecoration{ 0 };
};
/** @return The generated inline fragments. */
const std::vector<InlineFragment>& getInlineFragments() const { return mInlineFragments; }
// ── Inline tree builder API (stack-based, used by UIRichText) ───────────
/** Begin an inline box scope. All subsequent inline items are added as
* children of this box until popInlineBox() is called. An InlineItem::Box
* is created in the inline tree. */
void pushInlineBox( const Rectf& margin, const Rectf& padding, Float lineHeight,
const BaselineAlignValue& baselineAlign,
const Color& backgroundColor = Color::Transparent, Float borderWidth = 0,
const Color& borderColor = Color::Transparent, Uint32 textDecoration = 0,
InlineSource source = {}, Drawable* backgroundDrawable = nullptr,
Drawable* borderDrawable = nullptr,
bool backgroundDrawableUsesFragmentColor = false );
/** Close the current inline box scope. */
void popInlineBox();
/** Add a text run to the current inline context. */
void addInlineText( const String& text, const FontStyleConfig& style, const Rectf& margin,
const Rectf& padding, Float lineHeight,
const BaselineAlignValue& baselineAlign, InlineSource source = {} );
/** Add an atomic inline-level box to the current inline context. */
void addInlineAtomicBox( const Sizef& size, InlineFloat floatType, InlineClear clearType,
Float baseline, bool isLineBreak,
const BaselineAlignValue& baselineAlign, InlineSource source = {} );
protected:
std::vector<Block> mBlocks;
void rebuildInlineFragments();
std::vector<InlineItem> mInlineItems;
RenderSpan::InlinePath mInlinePath; // Path into the inline tree for the stack-based builder
std::vector<InlineFragment> mInlineFragments;
std::vector<RenderParagraph> mLines;
FontStyleConfig mDefaultStyle;
TextSelectionRange mSelection{ 0, 0 };

View File

@@ -1,12 +1,10 @@
#ifndef EE_UI_BLOCKLAYOUTER_HPP
#define EE_UI_BLOCKLAYOUTER_HPP
#include <eepp/core/containers.hpp>
#include <eepp/graphics/richtext.hpp>
#include <eepp/ui/uilayouter.hpp>
namespace EE::Graphics {
class RichText;
}
using namespace EE::Graphics;
namespace EE { namespace UI {
@@ -20,7 +18,22 @@ class EE_API BlockLayouter : public UILayouter {
Float getMaxIntrinsicWidth() override;
protected:
struct FragmentBucket {
SmallVector<const RichText::InlineFragment*, 4> textRuns;
SmallVector<const RichText::InlineFragment*, 4> boxes;
SmallVector<const RichText::InlineFragment*, 4> atomicBoxes;
void clear() {
textRuns.clear();
boxes.clear();
atomicBoxes.clear();
}
};
void positionRichTextChildren( RichText* rt );
UnorderedMap<void*, FragmentBucket> mTextNodeFragments;
UnorderedMap<void*, FragmentBucket> mWidgetFragments;
};
}} // namespace EE::UI

View File

@@ -215,6 +215,8 @@ class EE_API UINodeDrawable : public Drawable {
UIBackgroundDrawable& getBackgroundDrawable();
bool hasDrawableLayers() const;
bool isSmooth() const;
void setSmooth( bool smooth );

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
#include <eepp/core/containers.hpp>
#include <eepp/graphics/richtext.hpp>
#include <eepp/ui/blocklayouter.hpp>
#include <eepp/ui/uihtmlwidget.hpp>
@@ -148,7 +149,9 @@ void BlockLayouter::updateLayout() {
void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
const auto& lines = rt->getLines();
const auto& fragments = rt->getInlineFragments();
Node* child = mContainer->getFirstChild();
const bool hasStructuredFragments = !fragments.empty();
size_t currentLine = 0;
size_t currentSpan = 0;
@@ -159,10 +162,8 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
while ( currentSpan < line.spans.size() ) {
const auto& span = line.spans[currentSpan];
currentSpan++;
if ( auto custom = std::get_if<RichText::CustomBlock>( &span.block ) ) {
if ( !custom->isLineBreak )
return &span;
}
if ( span.type != RichText::RenderSpan::Type::Text && !span.isLineBreak )
return &span;
}
currentSpan = 0;
currentLine++;
@@ -171,6 +172,96 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
};
Int64 curCharIdx = 0;
const Rectf contentOffset = mContainer->getPixelsContentOffset();
for ( auto& bucket : mTextNodeFragments )
bucket.second.clear();
for ( auto& bucket : mWidgetFragments )
bucket.second.clear();
mTextNodeFragments.clear();
mWidgetFragments.clear();
mTextNodeFragments.reserve( fragments.size() );
mWidgetFragments.reserve( fragments.size() );
for ( const auto& fragment : fragments ) {
if ( fragment.source.ptr == nullptr )
continue;
auto* bucket = fragment.source.type == RichText::InlineSourceType::TextNode
? &mTextNodeFragments[fragment.source.ptr]
: fragment.source.type == RichText::InlineSourceType::Widget
? &mWidgetFragments[fragment.source.ptr]
: nullptr;
if ( bucket == nullptr )
continue;
switch ( fragment.type ) {
case RichText::InlineFragment::Type::TextRun:
bucket->textRuns.push_back( &fragment );
break;
case RichText::InlineFragment::Type::Box:
bucket->boxes.push_back( &fragment );
break;
case RichText::InlineFragment::Type::AtomicBox:
bucket->atomicBoxes.push_back( &fragment );
break;
}
}
auto toContainerBounds = [&]( const Rectf& bounds ) {
return Rectf( contentOffset.Left + bounds.Left, contentOffset.Top + bounds.Top,
contentOffset.Left + bounds.Right, contentOffset.Top + bounds.Bottom );
};
auto hasValidBounds = []( const Rectf& bounds ) {
return bounds.Left <= bounds.Right && bounds.Top <= bounds.Bottom;
};
auto expandBounds = [&]( Rectf& bounds, bool& valid, const Rectf& rect ) {
if ( !valid ) {
bounds = rect;
valid = true;
} else {
bounds.expand( rect );
}
};
auto getTextNodeFragmentBounds = [&]( UITextNode* textNode, Rectf& outBounds ) {
auto it = mTextNodeFragments.find( textNode );
if ( it == mTextNodeFragments.end() )
return false;
bool valid = false;
for ( const auto* fragment : it->second.textRuns )
expandBounds( outBounds, valid, toContainerBounds( fragment->bounds ) );
return valid;
};
auto getWidgetFragmentBounds = [&]( UIWidget* widget, Rectf& outBounds,
SpanHitBoxes* hitBoxes ) {
auto it = mWidgetFragments.find( widget );
if ( it == mWidgetFragments.end() )
return false;
bool valid = false;
for ( const auto* fragment : it->second.boxes ) {
Rectf hb = toContainerBounds( fragment->bounds );
if ( hitBoxes )
hitBoxes->push_back( hb );
expandBounds( outBounds, valid, hb );
}
return valid;
};
auto getAtomicWidgetFragmentBounds = [&]( UIWidget* widget, Rectf& outBounds ) {
auto it = mWidgetFragments.find( widget );
if ( it == mWidgetFragments.end() )
return false;
bool valid = false;
for ( const auto* fragment : it->second.atomicBoxes )
expandBounds( outBounds, valid, toContainerBounds( fragment->bounds ) );
return valid;
};
auto processNode = [&]( Node* node, auto& processNodeRef ) -> Rectf {
constexpr Float maxF = std::numeric_limits<Float>::max();
@@ -197,30 +288,34 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
p = p->getParent();
}
for ( const auto& line : lines ) {
bool passedText = false;
for ( const auto& rspan : line.spans ) {
if ( rspan.startCharIndex >= startChar && rspan.endCharIndex <= endChar ) {
Rectf hb( mContainer->getPixelsContentOffset().Left + rspan.position.x,
mContainer->getPixelsContentOffset().Top + line.y +
rspan.position.y,
mContainer->getPixelsContentOffset().Left + rspan.position.x +
rspan.size.getWidth(),
mContainer->getPixelsContentOffset().Top + line.y +
rspan.position.y + rspan.size.getHeight() );
textBounds.expand( hb );
} else if ( rspan.startCharIndex >= endChar ) {
passedText = true;
break;
bool hasFragments = getTextNodeFragmentBounds( tn, textBounds );
if ( !hasFragments && !hasStructuredFragments ) {
for ( const auto& line : lines ) {
bool passedText = false;
for ( const auto& rspan : line.spans ) {
if ( rspan.startCharIndex >= startChar &&
rspan.endCharIndex <= endChar ) {
Rectf hb( contentOffset.Left + rspan.position.x,
contentOffset.Top + line.y + rspan.position.y,
contentOffset.Left + rspan.position.x +
rspan.size.getWidth(),
contentOffset.Top + line.y + rspan.position.y +
rspan.size.getHeight() );
textBounds.expand( hb );
} else if ( rspan.startCharIndex >= endChar ) {
passedText = true;
break;
}
}
if ( passedText )
break;
}
if ( passedText )
break;
}
if ( textBounds.Left <= textBounds.Right && textBounds.Top <= textBounds.Bottom ) {
if ( hasValidBounds( textBounds ) ) {
tn->setPixelsPosition( textBounds.getPosition() - offset );
tn->setPixelsSize( textBounds.getSize() );
return textBounds;
}
}
@@ -257,18 +352,18 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
auto& hitBoxes = textSpan->getHitBoxes();
hitBoxes.clear();
if ( startChar < endChar ) {
bool hasWidgetFragments = getWidgetFragmentBounds( widget, bounds, &hitBoxes );
if ( !hasWidgetFragments && !hasStructuredFragments && startChar < endChar ) {
for ( const auto& line : lines ) {
bool passedText = false;
for ( const auto& rspan : line.spans ) {
if ( rspan.startCharIndex >= startChar && rspan.endCharIndex <= endChar ) {
Rectf hb( mContainer->getPixelsContentOffset().Left + rspan.position.x,
mContainer->getPixelsContentOffset().Top + line.y +
rspan.position.y,
mContainer->getPixelsContentOffset().Left + rspan.position.x +
rspan.size.getWidth(),
mContainer->getPixelsContentOffset().Top + line.y +
rspan.position.y + rspan.size.getHeight() );
Rectf hb( contentOffset.Left + rspan.position.x,
contentOffset.Top + line.y + rspan.position.y,
contentOffset.Left + rspan.position.x + rspan.size.getWidth(),
contentOffset.Top + line.y + rspan.position.y +
rspan.size.getHeight() );
hitBoxes.push_back( hb );
bounds.expand( hb );
@@ -303,7 +398,7 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
}
}
if ( bounds.Left <= bounds.Right && bounds.Top <= bounds.Bottom ) {
if ( hasValidBounds( bounds ) ) {
Vector2f boundsPos = bounds.getPosition();
widget->setPixelsPosition( boundsPos - offset );
@@ -338,16 +433,36 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
0 } );
} else {
curCharIdx += 1;
const auto* span = getNextCustomSpan();
if ( span ) {
Rectf atomicBounds( maxF, maxF, lowF, lowF );
if ( getAtomicWidgetFragmentBounds( widget, atomicBounds ) ) {
Rectf margin = widget->getLayoutPixelsMargin();
Vector2f targetPos( atomicBounds.Left + margin.Left,
atomicBounds.Top + margin.Top );
widget->setPixelsPosition( targetPos - offset );
if ( widget->getLayoutWidthPolicy() == SizePolicy::MatchParent &&
mContainer->getLayoutWidthPolicy() == SizePolicy::WrapContent ) {
Float contentWidth = eemax(
0.f, mContainer->getPixelsSize().getWidth() - contentOffset.Left -
contentOffset.Right - margin.Left - margin.Right );
if ( widget->getPixelsSize().getWidth() == 0 && contentWidth > 0 ) {
widget->setPixelsSize( contentWidth,
widget->getPixelsSize().getHeight() );
mResizedCount++;
}
}
bounds = Rectf( targetPos, atomicBounds.getSize() );
} else if ( !hasStructuredFragments ) {
const auto* span = getNextCustomSpan();
if ( span == nullptr )
return bounds;
size_t lineIdx = currentSpan > 0 ? currentLine : currentLine - 1;
Float lineY = lines[lineIdx].y;
Rectf margin = widget->getLayoutPixelsMargin();
Vector2f targetPos( mContainer->getPixelsContentOffset().Left +
span->position.x + margin.Left,
mContainer->getPixelsContentOffset().Top + lineY +
span->position.y + margin.Top );
Vector2f targetPos( contentOffset.Left + span->position.x + margin.Left,
contentOffset.Top + lineY + span->position.y + margin.Top );
widget->setPixelsPosition( targetPos - offset );
if ( widget->getLayoutWidthPolicy() == SizePolicy::MatchParent &&

View File

@@ -219,6 +219,10 @@ UIBackgroundDrawable& UINodeDrawable::getBackgroundDrawable() {
return mBackgroundColor;
}
bool UINodeDrawable::hasDrawableLayers() const {
return !mGroup.empty();
}
bool UINodeDrawable::isSmooth() const {
return mSmooth;
}

View File

@@ -1,11 +1,14 @@
#include <cmath>
#include <eepp/graphics/fontmanager.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/scopedop.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uilayouter.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uirichtext.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uistyle.hpp>
@@ -850,8 +853,8 @@ String UIRichText::UIRichText::collapseInternalWhitespace( const String& s ) {
return out;
}
static Float getCustomBlockBaseline( UIWidget* widget, const Sizef& widgetSize,
const Rectf& margin ) {
static Float getAtomicInlineBoxBaseline( UIWidget* widget, const Sizef& widgetSize,
const Rectf& margin ) {
Float fallbackBaseline = widgetSize.getHeight() + margin.Top + margin.Bottom;
if ( !widget->isType( UI_TYPE_HTML_WIDGET ) )
return fallbackBaseline;
@@ -877,27 +880,136 @@ static CSSBaselineAlignValue getWidgetBaselineAlign( UIWidget* widget ) {
return {};
}
static bool isDefaultBaselineAlign( const CSSBaselineAlignValue& align ) {
return align.type == CSSBaselineAlignment::Baseline || align.type == CSSBaselineAlignment::Auto;
static RichText::BaselineAlignValue toRichTextBaselineAlign( const CSSBaselineAlignValue& align ) {
RichText::BaselineAlignValue out;
out.value = align.value;
switch ( align.type ) {
case CSSBaselineAlignment::Sub:
out.type = RichText::BaselineAlignment::Sub;
break;
case CSSBaselineAlignment::Super:
out.type = RichText::BaselineAlignment::Super;
break;
case CSSBaselineAlignment::TextTop:
out.type = RichText::BaselineAlignment::TextTop;
break;
case CSSBaselineAlignment::TextBottom:
out.type = RichText::BaselineAlignment::TextBottom;
break;
case CSSBaselineAlignment::Middle:
out.type = RichText::BaselineAlignment::Middle;
break;
case CSSBaselineAlignment::Top:
out.type = RichText::BaselineAlignment::Top;
break;
case CSSBaselineAlignment::Bottom:
out.type = RichText::BaselineAlignment::Bottom;
break;
case CSSBaselineAlignment::Length:
out.type = RichText::BaselineAlignment::Length;
break;
case CSSBaselineAlignment::Percentage:
out.type = RichText::BaselineAlignment::Percentage;
break;
case CSSBaselineAlignment::Auto:
out.type = RichText::BaselineAlignment::Auto;
break;
case CSSBaselineAlignment::Baseline:
default:
out.type = RichText::BaselineAlignment::Baseline;
break;
}
return out;
}
static CSSBaselineAlignValue getEffectiveInlineBaselineAlign( Node* node, UILayout* container ) {
// vertical-align is not inherited, but nested inline boxes can be flattened into a single
// RichText span. Preserve the nearest explicit inline box alignment so the generated span
// still represents the parent inline box being aligned.
for ( Node* cur = node; cur && cur != container; cur = cur->getParent() ) {
if ( !cur->isWidget() )
continue;
UIWidget* widget = cur->asType<UIWidget>();
if ( !widget->isInlineDisplay() )
continue;
CSSBaselineAlignValue align = getWidgetBaselineAlign( widget );
if ( !isDefaultBaselineAlign( align ) )
return align;
static RichText::InlineFloat toRichTextFloat( CSSFloat val ) {
switch ( val ) {
case CSSFloat::Left:
return RichText::InlineFloat::Left;
case CSSFloat::Right:
return RichText::InlineFloat::Right;
case CSSFloat::None:
default:
return RichText::InlineFloat::None;
}
}
static RichText::InlineClear toRichTextClear( CSSClear val ) {
switch ( val ) {
case CSSClear::Left:
return RichText::InlineClear::Left;
case CSSClear::Right:
return RichText::InlineClear::Right;
case CSSClear::Both:
return RichText::InlineClear::Both;
case CSSClear::None:
default:
return RichText::InlineClear::None;
}
}
static RichText::InlineSource toRichTextTextSource( UITextNode* node ) {
return { RichText::InlineSourceType::TextNode, node };
}
static RichText::InlineSource toRichTextWidgetSource( UIWidget* widget ) {
return { RichText::InlineSourceType::Widget, widget };
}
static Float getInlineBorderWidth( UIWidget* widget ) {
if ( widget == nullptr || !widget->hasBorder() || widget->getBorder() == nullptr )
return 0.f;
const auto& borders = widget->getBorder()->getBorders();
return eemax( eemax( static_cast<Float>( borders.left.width ),
static_cast<Float>( borders.right.width ) ),
eemax( static_cast<Float>( borders.top.width ),
static_cast<Float>( borders.bottom.width ) ) );
}
static Color getInlineBorderColor( UIWidget* widget ) {
if ( widget == nullptr || !widget->hasBorder() || widget->getBorder() == nullptr )
return Color::Transparent;
const auto& borders = widget->getBorder()->getBorders();
if ( borders.top.width > 0 )
return borders.top.color;
if ( borders.right.width > 0 )
return borders.right.color;
if ( borders.bottom.width > 0 )
return borders.bottom.color;
if ( borders.left.width > 0 )
return borders.left.color;
return Color::Transparent;
}
struct InlineBackgroundDrawable {
Drawable* drawable{ nullptr };
bool usesFragmentColor{ false };
};
static InlineBackgroundDrawable getInlineBackgroundDrawable( UIWidget* widget,
const Color& backgroundColor ) {
if ( widget == nullptr || !( widget->getFlags() & UI_FILL_BACKGROUND ) ||
!widget->hasBackground() )
return {};
UINodeDrawable* background = widget->getBackground();
if ( background->getBackgroundColor() != Color::Transparent || background->hasDrawableLayers() )
return { background, false };
if ( backgroundColor != Color::Transparent && background->getBackgroundDrawable().hasRadius() )
return { &background->getBackgroundDrawable(), true };
return {};
}
static Drawable* getInlineBorderDrawable( UIWidget* widget ) {
return widget != nullptr && widget->hasBorder() && widget->getBorder() != nullptr
? widget->getBorder()
: nullptr;
}
void UIRichText::rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode ) {
richText.clear();
if ( container->isType( UI_TYPE_RICHTEXT ) ) {
@@ -956,12 +1068,13 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
FontStyleConfig style = selfSpan->getFontStyleConfig();
style.BackgroundColor = Color::Transparent;
richText.addSpan( selfText, style, Rectf::Zero, Rectf::Zero, 0,
selfSpan->getBaselineAlign() );
toRichTextBaselineAlign( selfSpan->getBaselineAlign() ) );
if ( shouldCollapse )
lastSpanEndsWithSpace = !selfText.empty() && selfText.back() == ' ';
}
}
int inlineBoxDepth = 0;
auto processNode = [&]( Node* node, auto& processNodeRef ) -> void {
// Helper: walk up through inline ancestors to find the logical prev/next widget
auto findLogicalPrev = []( Node* n ) -> Node* {
@@ -1040,11 +1153,12 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
} else {
style = richText.getFontStyleConfig();
}
CSSBaselineAlignValue baselineAlign;
if ( node->getParent()->isWidget() )
baselineAlign = getEffectiveInlineBaselineAlign(
node->getParent()->asType<UIWidget>(), container );
richText.addSpan( text, style, Rectf::Zero, Rectf::Zero, 0, baselineAlign );
if ( inlineBoxDepth > 0 )
richText.addInlineText( text, style, Rectf::Zero, Rectf::Zero, 0, {},
toRichTextTextSource( textNode ) );
else
richText.addSpan( text, style, Rectf::Zero, Rectf::Zero, 0, {},
toRichTextTextSource( textNode ) );
return;
}
@@ -1064,16 +1178,37 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
span->setLayoutCharCount( 0 );
Rectf margin = span->getLayoutPixelsMargin();
Rectf padding = span->getPixelsPadding();
Float borderWidth = getInlineBorderWidth( span );
Color borderColor = getInlineBorderColor( span );
bool hasOwnText = !span->getText().empty() && NULL != span->getFontStyleConfig().Font;
Float spanLineHeight = 0;
if ( span->isInlineBlock() ) {
Float spanLineHeight = span->getLineHeightPx();
Float parentLineHeight = 0;
Node* parent = span->getParent();
if ( parent != nullptr && parent->isType( UI_TYPE_RICHTEXT ) )
parentLineHeight = parent->asType<UIRichText>()->getLineHeightPx();
else if ( parent != nullptr && parent->isType( UI_TYPE_TEXTSPAN ) )
parentLineHeight = parent->asType<UITextSpan>()->getLineHeightPx();
if ( spanLineHeight > 0 && std::abs( spanLineHeight - parentLineHeight ) <= 0.01f )
spanLineHeight = 0;
if ( spanLineHeight <= 0 && span->isInlineBlock() ) {
auto& fontStyle = span->getFontStyleConfig();
if ( fontStyle.Font )
spanLineHeight =
(Float)fontStyle.Font->getFontHeight( fontStyle.CharacterSize );
}
auto backgroundDrawable =
getInlineBackgroundDrawable( span, span->getFontStyleConfig().BackgroundColor );
richText.pushInlineBox( margin, padding, spanLineHeight,
toRichTextBaselineAlign( span->getBaselineAlign() ),
span->getFontStyleConfig().BackgroundColor, borderWidth,
borderColor, span->getTextDecoration(),
toRichTextWidgetSource( span ), backgroundDrawable.drawable,
getInlineBorderDrawable( span ),
backgroundDrawable.usesFragmentColor );
inlineBoxDepth++;
if ( hasOwnText ) {
String::View spanText = span->getText().view();
@@ -1095,20 +1230,14 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
spanText = spanText.substr( 1 );
if ( !spanText.empty() ) {
richText.addSpan( spanText, span->getFontStyleConfig(), margin, padding,
spanLineHeight,
getEffectiveInlineBaselineAlign( span, container ) );
richText.addInlineText( spanText, span->getFontStyleConfig(), Rectf::Zero,
Rectf::Zero, 0, {} );
span->setLayoutCharCount( spanText.length() );
if ( shouldCollapse )
lastSpanEndsWithSpace = spanText.back() == ' ';
} else {
span->setLayoutCharCount( 0 );
}
} else if ( margin.Left > 0 || margin.Top > 0 || padding.Left > 0 || padding.Top > 0 ) {
Rectf leftOnly( margin.Left, margin.Top, 0, 0 );
Rectf padLeftOnly( padding.Left, padding.Top, 0, 0 );
richText.addSpan( "", span->getFontStyleConfig(), leftOnly, padLeftOnly, 0,
getEffectiveInlineBaselineAlign( span, container ) );
}
Node* spanChild = span->getFirstChild();
@@ -1120,17 +1249,12 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
spanChild = spanChild->getNextNode();
}
if ( !hasOwnText && ( margin.Right > 0 || margin.Bottom > 0 || padding.Right > 0 ||
padding.Bottom > 0 ) ) {
Rectf rightOnly( 0, 0, margin.Right, margin.Bottom );
Rectf padRightOnly( 0, 0, padding.Right, padding.Bottom );
richText.addSpan( "", span->getFontStyleConfig(), rightOnly, padRightOnly, 0,
getEffectiveInlineBaselineAlign( span, container ) );
}
if ( shouldCollapse && span->isInlineBlock() )
lastSpanEndsWithSpace = true;
inlineBoxDepth--;
richText.popInlineBox();
handled = true;
}
@@ -1209,9 +1333,11 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
Sizef customSize( w + margin.Left + margin.Right,
size.getHeight() + margin.Top + margin.Bottom );
richText.addCustomSize( customSize, floatType, clearType,
getCustomBlockBaseline( widget, size, margin ),
getWidgetBaselineAlign( widget ) );
richText.addCustomSize( customSize, toRichTextFloat( floatType ),
toRichTextClear( clearType ),
getAtomicInlineBoxBaseline( widget, size, margin ),
toRichTextBaselineAlign( getWidgetBaselineAlign( widget ) ),
toRichTextWidgetSource( widget ) );
if ( widget->isType( UI_TYPE_TEXTSPAN ) &&
widget->asType<UITextSpan>()->isInlineBlock() &&

View File

@@ -1,6 +1,7 @@
#include "compareimages.hpp"
#include "utest.hpp"
#include <algorithm>
#include <eepp/graphics/fontfamily.hpp>
#include <eepp/graphics/fonttruetype.hpp>
#include <eepp/graphics/primitives.hpp>
@@ -11,14 +12,18 @@
#include <eepp/system/sys.hpp>
#include <eepp/ui/tools/htmlformatter.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uibackgrounddrawable.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
#include <eepp/ui/uihtmltable.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uirichtext.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uitextnode.hpp>
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/window/engine.hpp>
#include <limits>
using namespace EE;
using namespace EE::Graphics;
@@ -217,7 +222,7 @@ UTEST( RichText, BaselineAlignment ) {
Engine::destroySingleton();
}
UTEST( RichText, VerticalAlignCustomBlocks ) {
UTEST( RichText, VerticalAlignAtomicBoxes ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Vertical Align",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
@@ -231,42 +236,46 @@ UTEST( RichText, VerticalAlignCustomBlocks ) {
baselineRt.getFontStyleConfig().Font = font;
baselineRt.getFontStyleConfig().CharacterSize = 20;
baselineRt.addSpan( "A", nullptr, 20 );
baselineRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f );
baselineRt.addCustomSize( Sizef( 20, 20 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 10.f );
baselineRt.getSize();
ASSERT_EQ( baselineRt.getLines().front().spans.size(), (size_t)2 );
Float baselineY = baselineRt.getLines().front().spans[1].position.y;
CSSBaselineAlignValue middleAlign;
middleAlign.type = CSSBaselineAlignment::Middle;
RichText::BaselineAlignValue middleAlign;
middleAlign.type = RichText::BaselineAlignment::Middle;
RichText middleRt;
middleRt.getFontStyleConfig().Font = font;
middleRt.getFontStyleConfig().CharacterSize = 20;
middleRt.addSpan( "A", nullptr, 20 );
middleRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f, middleAlign );
middleRt.addCustomSize( Sizef( 20, 20 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 10.f, middleAlign );
middleRt.getSize();
ASSERT_EQ( middleRt.getLines().front().spans.size(), (size_t)2 );
EXPECT_GT( middleRt.getLines().front().spans[1].position.y, baselineY );
CSSBaselineAlignValue lengthAlign;
lengthAlign.type = CSSBaselineAlignment::Length;
RichText::BaselineAlignValue lengthAlign;
lengthAlign.type = RichText::BaselineAlignment::Length;
lengthAlign.value = 4.f;
RichText lengthRt;
lengthRt.getFontStyleConfig().Font = font;
lengthRt.getFontStyleConfig().CharacterSize = 20;
lengthRt.addSpan( "A", nullptr, 20 );
lengthRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f, lengthAlign );
lengthRt.addCustomSize( Sizef( 20, 20 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 10.f, lengthAlign );
lengthRt.getSize();
ASSERT_EQ( lengthRt.getLines().front().spans.size(), (size_t)2 );
EXPECT_NEAR( lengthRt.getLines().front().spans[1].position.y, baselineY - 4.f, 0.001f );
CSSBaselineAlignValue percentAlign;
percentAlign.type = CSSBaselineAlignment::Percentage;
RichText::BaselineAlignValue percentAlign;
percentAlign.type = RichText::BaselineAlignment::Percentage;
percentAlign.value = 50.f;
RichText percentRt;
percentRt.getFontStyleConfig().Font = font;
percentRt.getFontStyleConfig().CharacterSize = 20;
percentRt.addSpan( "A", nullptr, 20 );
percentRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f, percentAlign );
percentRt.addCustomSize( Sizef( 20, 20 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 10.f, percentAlign );
percentRt.getSize();
ASSERT_EQ( percentRt.getLines().front().spans.size(), (size_t)2 );
EXPECT_NEAR( percentRt.getLines().front().spans[1].position.y, baselineY - 10.f, 0.001f );
@@ -274,7 +283,400 @@ UTEST( RichText, VerticalAlignCustomBlocks ) {
Engine::destroySingleton();
}
UTEST( RichText, CustomBlockBaselineAlignment ) {
UTEST( RichText, InlineTextUsesActiveInlineBoxAlignment ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Inline Alignment",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText::BaselineAlignValue bottomAlign;
bottomAlign.type = RichText::BaselineAlignment::Bottom;
RichText richText;
richText.setFontStyleConfig( style );
richText.addSpan( "A", style );
richText.pushInlineBox( Rectf::Zero, Rectf::Zero, 0, bottomAlign );
richText.pushInlineBox( Rectf::Zero, Rectf::Zero, 0, {} );
richText.addInlineText( "x", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.popInlineBox();
richText.popInlineBox();
richText.addSpan( "B", style );
richText.updateLayout();
const auto& lines = richText.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines[0].spans.size(), (size_t)3 );
ASSERT_EQ( lines[0].spans[1].type, RichText::RenderSpan::Type::Text );
EXPECT_EQ( lines[0].spans[1].baselineAlign.type, RichText::BaselineAlignment::Baseline );
bool foundTextFragment = false;
for ( const auto& fragment : richText.getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::TextRun &&
fragment.itemPath.size() == 3 ) {
foundTextFragment = true;
EXPECT_EQ( fragment.baselineAlign.type, RichText::BaselineAlignment::Bottom );
}
}
EXPECT_TRUE( foundTextFragment );
Engine::destroySingleton();
}
UTEST( RichText, InlineAncestorLineHeightContributesToLineHeight ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Inline Box Metrics",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText baseline;
baseline.setFontStyleConfig( style );
baseline.addSpan( "A", style );
baseline.updateLayout();
ASSERT_EQ( baseline.getLines().size(), (size_t)1 );
Float baselineHeight = baseline.getLines().front().height;
RichText lineHeightBox;
lineHeightBox.setFontStyleConfig( style );
lineHeightBox.pushInlineBox( Rectf::Zero, Rectf::Zero, baselineHeight + 20.f, {} );
lineHeightBox.addInlineText( "A", style, Rectf::Zero, Rectf::Zero, 0, {} );
lineHeightBox.popInlineBox();
lineHeightBox.updateLayout();
ASSERT_EQ( lineHeightBox.getLines().size(), (size_t)1 );
EXPECT_GE( lineHeightBox.getLines().front().height, baselineHeight + 20.f );
const RichText::InlineFragment* boxFragment = nullptr;
const RichText::InlineFragment* textFragment = nullptr;
for ( const auto& fragment : lineHeightBox.getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::Box )
boxFragment = &fragment;
else if ( fragment.type == RichText::InlineFragment::Type::TextRun )
textFragment = &fragment;
}
ASSERT_TRUE( boxFragment != nullptr );
ASSERT_TRUE( textFragment != nullptr );
EXPECT_GE( boxFragment->bounds.getHeight(), baselineHeight + 20.f );
EXPECT_LT( boxFragment->bounds.Top, textFragment->bounds.Top );
EXPECT_GT( boxFragment->bounds.Bottom, textFragment->bounds.Bottom );
Engine::destroySingleton();
}
UTEST( RichText, FloatAwareInlineLayoutUsesTreeOrder ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Float Inline Tree Order",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText richText;
richText.setFontStyleConfig( style );
richText.addInlineAtomicBox( Sizef( 30, 20 ), RichText::InlineFloat::Left,
RichText::InlineClear::None, 20, false, {} );
richText.addInlineText( "A", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.updateLayout();
const auto& lines = richText.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)2 );
EXPECT_EQ( lines.front().spans[0].type, RichText::RenderSpan::Type::AtomicBox );
ASSERT_EQ( lines.front().spans[1].type, RichText::RenderSpan::Type::Text );
ASSERT_TRUE( lines.front().spans[1].text != nullptr );
EXPECT_STRINGEQ( lines.front().spans[1].text->getString(), "A" );
EXPECT_GE( lines.front().spans[1].position.x, 30.f );
Engine::destroySingleton();
}
UTEST( RichText, RenderSpanPayloadSupportsDrawableAndAtomicSelection ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText RenderSpan Blocks",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
class TestDrawable : public Drawable {
public:
TestDrawable() : Drawable( Drawable::CUSTOM ) {}
Sizef getSize() override { return Sizef( 8, 6 ); }
Sizef getPixelsSize() override { return Sizef( 8, 6 ); }
void draw() override { drawCount++; }
void draw( const Vector2f& ) override { drawCount++; }
void draw( const Vector2f&, const Sizef& ) override { drawCount++; }
bool isStateful() override { return false; }
int drawCount{ 0 };
};
auto drawable = std::make_shared<TestDrawable>();
RichText richText;
richText.setFontStyleConfig( style );
richText.addInlineText( "A", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.addDrawable( drawable );
richText.addInlineAtomicBox( Sizef( 5, 4 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 4, false, {} );
richText.addInlineText( "B", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.updateLayout();
const auto& lines = richText.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)4 );
EXPECT_EQ( lines.front().spans[0].type, RichText::RenderSpan::Type::Text );
EXPECT_EQ( lines.front().spans[1].type, RichText::RenderSpan::Type::Drawable );
EXPECT_EQ( lines.front().spans[2].type, RichText::RenderSpan::Type::AtomicBox );
EXPECT_EQ( lines.front().spans[3].type, RichText::RenderSpan::Type::Text );
EXPECT_EQ( richText.getCharacterCount(), 4 );
richText.setSelection( { 0, richText.getCharacterCount() } );
EXPECT_STRINGEQ( richText.getSelectionString(), "A B" );
richText.draw( 0, 0 );
EXPECT_EQ( drawable->drawCount, 1 );
Engine::destroySingleton();
}
UTEST( RichText, InlineBoxHorizontalEdgesContributeToAdvance ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Inline Box Edges",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText richText;
richText.setFontStyleConfig( style );
richText.addSpan( "A", style );
richText.pushInlineBox( Rectf( 5, 0, 7, 0 ), Rectf( 11, 0, 13, 0 ), 0, {} );
richText.addInlineText( "B", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.popInlineBox();
richText.addSpan( "C", style );
richText.updateLayout();
const auto& lines = richText.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)3 );
const auto& a = lines.front().spans[0];
const auto& b = lines.front().spans[1];
const auto& c = lines.front().spans[2];
EXPECT_NEAR( b.position.x, a.position.x + a.size.getWidth() + 16.f, 0.001f );
EXPECT_NEAR( c.position.x, b.position.x + b.size.getWidth() + 20.f, 0.001f );
EXPECT_NEAR( lines.front().width,
a.size.getWidth() + b.size.getWidth() + c.size.getWidth() + 36.f, 0.001f );
EXPECT_NEAR( richText.getMaxIntrinsicWidth(), lines.front().width, 0.001f );
RichText atomicRichText;
atomicRichText.setFontStyleConfig( style );
atomicRichText.addSpan( "A", style );
atomicRichText.pushInlineBox( Rectf( 3, 0, 5, 0 ), Rectf( 7, 0, 11, 0 ), 0, {} );
atomicRichText.addInlineAtomicBox( Sizef( 17, 9 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 8, false, {} );
atomicRichText.popInlineBox();
atomicRichText.addSpan( "C", style );
atomicRichText.updateLayout();
const auto& atomicLines = atomicRichText.getLines();
ASSERT_EQ( atomicLines.size(), (size_t)1 );
ASSERT_EQ( atomicLines.front().spans.size(), (size_t)3 );
const auto& atomicA = atomicLines.front().spans[0];
const auto& atomicBox = atomicLines.front().spans[1];
const auto& atomicC = atomicLines.front().spans[2];
EXPECT_NEAR( atomicBox.position.x, atomicA.position.x + atomicA.size.getWidth() + 10.f,
0.001f );
EXPECT_NEAR( atomicC.position.x, atomicBox.position.x + atomicBox.size.getWidth() + 16.f,
0.001f );
EXPECT_NEAR( atomicRichText.getMaxIntrinsicWidth(), atomicLines.front().width, 0.001f );
Engine::destroySingleton();
}
UTEST( RichText, HitTestingSnapsAcrossInlineBoxSpacing ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Inline Box Hit Test",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText richText;
richText.setFontStyleConfig( style );
richText.addSpan( "A", style );
richText.pushInlineBox( Rectf::Zero, Rectf( 20, 0, 20, 0 ), 0, {} );
richText.addInlineText( "B", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.popInlineBox();
richText.addSpan( "C", style );
richText.updateLayout();
const auto& lines = richText.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)3 );
const auto& a = lines.front().spans[0];
const auto& b = lines.front().spans[1];
const auto& c = lines.front().spans[2];
const Int64 beforeInlineText = b.startCharIndex;
const Int64 afterInlineText = b.endCharIndex;
const Int32 lineY = static_cast<Int32>( lines.front().y + b.position.y + 1 );
EXPECT_EQ( richText.findCharacterFromPos(
{ static_cast<Int32>( a.position.x + a.size.getWidth() + 5 ), lineY } ),
beforeInlineText );
EXPECT_EQ( richText.findCharacterFromPos(
{ static_cast<Int32>( b.position.x + b.size.getWidth() + 5 ), lineY } ),
afterInlineText );
EXPECT_EQ( c.startCharIndex, afterInlineText );
Engine::destroySingleton();
}
UTEST( RichText, SelectionRectsUseInlineFragments ) {
Engine::instance()->createWindow(
WindowSettings( 800, 600, "RichText Inline Fragment Selection", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText richText;
richText.setFontStyleConfig( style );
richText.addSpan( "A", style );
richText.pushInlineBox( Rectf::Zero, Rectf( 20, 0, 20, 0 ), 0, {} );
richText.addInlineText( "B", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.popInlineBox();
richText.addSpan( "C", style );
richText.updateLayout();
const RichText::InlineFragment* selectedFragment = nullptr;
for ( const auto& fragment : richText.getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::TextRun && fragment.text &&
fragment.text->getString() == "B" ) {
selectedFragment = &fragment;
break;
}
}
ASSERT_TRUE( selectedFragment != nullptr );
EXPECT_LT( selectedFragment->startCharIndex, selectedFragment->endCharIndex );
richText.setSelection( { selectedFragment->startCharIndex, selectedFragment->endCharIndex } );
auto rects = richText.getSelectionRects();
ASSERT_EQ( rects.size(), (size_t)1 );
EXPECT_NEAR( rects[0].Left, selectedFragment->bounds.Left, 0.001f );
EXPECT_NEAR( rects[0].Top, selectedFragment->bounds.Top, 0.001f );
EXPECT_NEAR( rects[0].Right, selectedFragment->bounds.Right, 0.001f );
EXPECT_NEAR( rects[0].Bottom, selectedFragment->bounds.Bottom, 0.001f );
Engine::destroySingleton();
}
UTEST( RichText, InlineParentTextDecorationReachesFragments ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Inline Decoration",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font->loaded() );
FontStyleConfig style;
style.Font = font;
style.CharacterSize = 20;
style.FontColor = Color::White;
RichText richText;
richText.setFontStyleConfig( style );
richText.pushInlineBox( Rectf::Zero, Rectf::Zero, 0, {}, Color::Transparent, 0,
Color::Transparent, Text::Underlined );
richText.pushInlineBox( Rectf::Zero, Rectf::Zero, 0, {} );
richText.addInlineText( "child", style, Rectf::Zero, Rectf::Zero, 0, {} );
richText.popInlineBox();
richText.popInlineBox();
richText.updateLayout();
const auto& lines = richText.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans[0].type, RichText::RenderSpan::Type::Text );
ASSERT_TRUE( lines.front().spans[0].text != nullptr );
EXPECT_TRUE( ( lines.front().spans[0].text->getStyle() & Text::Underlined ) != 0 );
const RichText::InlineFragment* textFragment = nullptr;
const RichText::InlineFragment* outerFragment = nullptr;
for ( const auto& fragment : richText.getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::TextRun )
textFragment = &fragment;
else if ( fragment.type == RichText::InlineFragment::Type::Box &&
fragment.itemPath.size() == 1 )
outerFragment = &fragment;
}
ASSERT_TRUE( textFragment != nullptr );
ASSERT_TRUE( outerFragment != nullptr );
EXPECT_TRUE( ( textFragment->textDecoration & Text::Underlined ) != 0 );
EXPECT_TRUE( ( outerFragment->textDecoration & Text::Underlined ) != 0 );
Engine::destroySingleton();
}
UTEST( RichText, AtomicInlineBoxBaselineAlignment ) {
Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Custom Block Baseline",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ) );
@@ -288,7 +690,8 @@ UTEST( RichText, CustomBlockBaselineAlignment ) {
richText.getFontStyleConfig().Font = font;
richText.addSpan( "Large", nullptr, 30 );
richText.addSpan( "Small", nullptr, 12 );
richText.addCustomSize( Sizef( 24, 30 ), UI::CSSFloat::None, UI::CSSClear::None, 12.f );
richText.addCustomSize( Sizef( 24, 30 ), RichText::InlineFloat::None,
RichText::InlineClear::None, 12.f );
richText.getSize();
@@ -456,24 +859,27 @@ UTEST( UIRichText, IntegrationAndLayoutVerification ) {
sceneNode->update( Time::Zero );
auto graphicsRt = rt->getRichText();
const auto& blocks = graphicsRt.getBlocks();
const auto& lines = graphicsRt.getLines();
ASSERT_EQ( blocks.size(), (size_t)4 );
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)4 );
// Check Text block
EXPECT_TRUE( std::holds_alternative<RichText::SpanBlock>( blocks[1] ) );
auto text1 = std::get<RichText::SpanBlock>( blocks[1] ).text;
// Check Text span
ASSERT_EQ( lines.front().spans[1].type, RichText::RenderSpan::Type::Text );
auto text1 = lines.front().spans[1].text;
ASSERT_TRUE( text1 != nullptr );
EXPECT_TRUE( text1->getFillColor() == Color::fromString( "#FF0000" ) );
// Check CustomSize block
EXPECT_TRUE( std::holds_alternative<RichText::CustomBlock>( blocks[2] ) );
EXPECT_EQ( std::get<RichText::CustomBlock>( blocks[2] ).size.getWidth(),
PixelDensity::dpToPx( 50 ) );
// Check atomic widget span
ASSERT_EQ( lines.front().spans[2].type, RichText::RenderSpan::Type::AtomicBox );
EXPECT_EQ( lines.front().spans[2].size.getWidth(), PixelDensity::dpToPx( 50 ) );
UI::UIWidget* placeholder = rt->find<UI::UIWidget>( "placeholder" );
ASSERT_TRUE( placeholder != nullptr );
auto text0 = std::get<RichText::SpanBlock>( blocks[0] ).text;
ASSERT_EQ( lines.front().spans[0].type, RichText::RenderSpan::Type::Text );
auto text0 = lines.front().spans[0].text;
ASSERT_TRUE( text0 != nullptr );
Vector2f pos = placeholder->getPixelsPosition();
Float expectedX = text0->getTextWidth() + text1->getTextWidth();
EXPECT_NEAR( pos.x, expectedX, 2.0f );
@@ -481,7 +887,7 @@ UTEST( UIRichText, IntegrationAndLayoutVerification ) {
destroyRichTextScene( sceneNode );
}
UTEST( RichText, VirtualLineBreakSeparatesCustomBlocks ) {
UTEST( RichText, VirtualLineBreakSeparatesAtomicBoxes ) {
RichText rt;
rt.addCustomSize( { 10, 5 } );
rt.addLineBreak();
@@ -550,18 +956,16 @@ UTEST( UIRichText, NestedWidgetsIntegration ) {
sceneNode->draw();
auto graphicsRt = rt->getRichText();
const auto& blocks = graphicsRt.getBlocks();
const auto& lines = graphicsRt.getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
ASSERT_EQ( lines.front().spans.size(), (size_t)4 );
ASSERT_EQ( blocks.size(), (size_t)4 );
EXPECT_EQ( lines.front().spans[0].type, RichText::RenderSpan::Type::Text );
EXPECT_EQ( lines.front().spans[1].type, RichText::RenderSpan::Type::Text );
EXPECT_EQ( lines.front().spans[2].type, RichText::RenderSpan::Type::AtomicBox );
EXPECT_EQ( lines.front().spans[3].type, RichText::RenderSpan::Type::Text );
// Check block types
EXPECT_TRUE( std::holds_alternative<RichText::SpanBlock>( blocks[0] ) );
EXPECT_TRUE( std::holds_alternative<RichText::SpanBlock>( blocks[1] ) );
EXPECT_TRUE( std::holds_alternative<RichText::CustomBlock>( blocks[2] ) );
EXPECT_TRUE( std::holds_alternative<RichText::SpanBlock>( blocks[3] ) );
EXPECT_EQ( std::get<RichText::CustomBlock>( blocks[2] ).size.getWidth(),
PixelDensity::dpToPx( 50 ) );
EXPECT_EQ( lines.front().spans[2].size.getWidth(), PixelDensity::dpToPx( 50 ) );
UI::UIWidget* strongNode = rt->find<UI::UIWidget>( "strong" );
ASSERT_TRUE( strongNode != nullptr );
@@ -569,8 +973,10 @@ UTEST( UIRichText, NestedWidgetsIntegration ) {
UI::UIWidget* placeholder = rt->find<UI::UIWidget>( "placeholder" );
ASSERT_TRUE( placeholder != nullptr );
auto text0 = std::get<RichText::SpanBlock>( blocks[0] ).text;
auto text1 = std::get<RichText::SpanBlock>( blocks[1] ).text;
auto text0 = lines.front().spans[0].text;
auto text1 = lines.front().spans[1].text;
ASSERT_TRUE( text0 != nullptr );
ASSERT_TRUE( text1 != nullptr );
Vector2f pos = placeholder->getScreenPos();
Float expectedX = text0->getTextWidth() + text1->getTextWidth();
@@ -583,6 +989,356 @@ UTEST( UIRichText, NestedWidgetsIntegration ) {
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineTreePreservesNestedInlineBoxes ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="300dp" layout_height="wrap_content">
Hello <span id="outer">before <a id="link" href="#">link</a> after</span> tail
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
sceneNode->update( Time::Zero );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
auto graphicsRt = rt->getRichText();
const auto& inlineItems = graphicsRt.getInlineItems();
ASSERT_GE( inlineItems.size(), (size_t)3 );
ASSERT_TRUE( inlineItems[1].isBox() );
const auto& outer = inlineItems[1].asBox();
ASSERT_EQ( outer.children.size(), (size_t)3 );
EXPECT_TRUE( outer.children[0].isTextRun() );
ASSERT_TRUE( outer.children[1].isBox() );
EXPECT_TRUE( outer.children[2].isTextRun() );
const auto& link = outer.children[1].asBox();
ASSERT_EQ( link.children.size(), (size_t)1 );
EXPECT_TRUE( link.children[0].isTextRun() );
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineTreeVerticalAlignStaysOnParentBox ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="300dp" layout_height="wrap_content">
A<span id="outer" vertical-align="bottom"><a id="link" href="#">link</a></span>B
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
sceneNode->update( Time::Zero );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
auto graphicsRt = rt->getRichText();
const auto& inlineItems = graphicsRt.getInlineItems();
ASSERT_GE( inlineItems.size(), (size_t)3 );
ASSERT_TRUE( inlineItems[1].isBox() );
const auto& outer = inlineItems[1].asBox();
EXPECT_EQ( outer.baselineAlign.type, RichText::BaselineAlignment::Bottom );
ASSERT_EQ( outer.children.size(), (size_t)1 );
ASSERT_TRUE( outer.children[0].isBox() );
const auto& link = outer.children[0].asBox();
EXPECT_EQ( link.baselineAlign.type, RichText::BaselineAlignment::Baseline );
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineParentCreatesFragmentsAcrossWrappedLines ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="90dp" layout_height="wrap_content" font-size="18dp">
x <span id="outer" vertical-align="bottom" background-color="#00ff00">alpha beta gamma delta epsilon zeta</span> y
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
sceneNode->update( Time::Zero );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
UI::UITextSpan* outerSpan = sceneNode->find<UI::UITextSpan>( "outer" );
ASSERT_TRUE( outerSpan != nullptr );
auto graphicsRt = rt->getRichText();
const auto& inlineItems = graphicsRt.getInlineItems();
ASSERT_GE( inlineItems.size(), (size_t)2 );
RichText::RenderSpan::InlinePath outerPath;
for ( size_t i = 0; i < inlineItems.size(); ++i ) {
if ( inlineItems[i].isBox() &&
inlineItems[i].asBox().baselineAlign.type == RichText::BaselineAlignment::Bottom ) {
outerPath = { i };
break;
}
}
ASSERT_FALSE( outerPath.empty() );
size_t outerFragmentCount = 0;
size_t firstLine = std::numeric_limits<size_t>::max();
size_t lastLine = 0;
SmallVector<Rectf, 4> outerFragmentBounds;
SmallVector<const RichText::InlineFragment*, 4> outerFragments;
for ( const auto& fragment : graphicsRt.getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::Box &&
fragment.itemPath == outerPath ) {
outerFragmentCount++;
firstLine = std::min( firstLine, fragment.lineIndex );
lastLine = std::max( lastLine, fragment.lineIndex );
outerFragments.push_back( &fragment );
Rectf fragmentBounds = fragment.bounds;
fragmentBounds.move(
{ rt->getPixelsContentOffset().Left, rt->getPixelsContentOffset().Top } );
fragmentBounds.move( -outerSpan->getPixelsPosition() );
outerFragmentBounds.push_back( fragmentBounds );
EXPECT_GT( fragment.bounds.getWidth(), 0 );
EXPECT_GT( fragment.bounds.getHeight(), 0 );
EXPECT_EQ( fragment.baselineAlign.type, RichText::BaselineAlignment::Bottom );
EXPECT_EQ( fragment.backgroundColor.getValue(),
Color::fromString( "#00ff00" ).getValue() );
}
}
EXPECT_GE( outerFragmentCount, (size_t)2 );
EXPECT_LT( firstLine, lastLine );
ASSERT_FALSE( outerFragments.empty() );
for ( const auto* fragment : outerFragments ) {
if ( fragment->lineIndex == firstLine )
EXPECT_TRUE( fragment->startsInlineBox );
else
EXPECT_FALSE( fragment->startsInlineBox );
if ( fragment->lineIndex == lastLine )
EXPECT_TRUE( fragment->endsInlineBox );
else
EXPECT_FALSE( fragment->endsInlineBox );
}
ASSERT_EQ( outerSpan->getHitBoxes().size(), outerFragmentBounds.size() );
for ( size_t i = 0; i < outerFragmentBounds.size(); ++i ) {
EXPECT_NEAR( outerSpan->getHitBoxes()[i].Left, outerFragmentBounds[i].Left, 0.001f );
EXPECT_NEAR( outerSpan->getHitBoxes()[i].Top, outerFragmentBounds[i].Top, 0.001f );
EXPECT_NEAR( outerSpan->getHitBoxes()[i].Right, outerFragmentBounds[i].Right, 0.001f );
EXPECT_NEAR( outerSpan->getHitBoxes()[i].Bottom, outerFragmentBounds[i].Bottom, 0.001f );
}
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineParentLineHeightFromCssContributesToFragments ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="300dp" layout_height="wrap_content" font-size="18dp">
A<span id="outer" line-height="48dp">line</span>B
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
sceneNode->update( Time::Zero );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
UI::UITextSpan* outerSpan = sceneNode->find<UI::UITextSpan>( "outer" );
ASSERT_TRUE( outerSpan != nullptr );
auto graphicsRt = rt->getRichText();
ASSERT_EQ( graphicsRt.getLines().size(), (size_t)1 );
EXPECT_GE( graphicsRt.getLines().front().height, PixelDensity::dpToPx( 48 ) );
const RichText::InlineFragment* boxFragment = nullptr;
const RichText::InlineFragment* textFragment = nullptr;
for ( const auto& fragment : graphicsRt.getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::Box &&
fragment.source.type == RichText::InlineSourceType::Widget &&
fragment.source.ptr == outerSpan ) {
boxFragment = &fragment;
} else if ( fragment.type == RichText::InlineFragment::Type::TextRun &&
fragment.itemPath.size() > 1 ) {
textFragment = &fragment;
}
}
ASSERT_TRUE( boxFragment != nullptr );
ASSERT_TRUE( textFragment != nullptr );
EXPECT_GE( boxFragment->bounds.getHeight(), PixelDensity::dpToPx( 48 ) );
EXPECT_LT( boxFragment->bounds.Top, textFragment->bounds.Top );
EXPECT_GT( boxFragment->bounds.Bottom, textFragment->bounds.Bottom );
EXPECT_GE( outerSpan->getPixelsSize().getHeight(), PixelDensity::dpToPx( 48 ) );
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineParentBorderIsPreservedInFragments ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="300dp" layout_height="wrap_content" font-size="18dp">
A<span id="outer" padding="3dp" background-color="#00ff00">boxed</span>B
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
UI::UITextSpan* outerSpan = sceneNode->find<UI::UITextSpan>( "outer" );
ASSERT_TRUE( outerSpan != nullptr );
outerSpan->setBorderWidth( 2 );
outerSpan->getBorder()->setColor( Color::White );
outerSpan->getBorder()->setColorTop( Color::Red );
outerSpan->getBorder()->setColorRight( Color::Red );
outerSpan->getBorder()->setColorBottom( Color::Red );
outerSpan->getBorder()->setColorLeft( Color::Red );
UIRichText::rebuildRichText( rt, *rt->getRichTextPtr() );
rt->getRichTextPtr()->updateLayout();
auto graphicsRt = rt->getRichText();
const auto& fragments = graphicsRt.getInlineFragments();
const RichText::InlineFragment* boxFragment = nullptr;
for ( const auto& fragment : fragments ) {
if ( fragment.type == RichText::InlineFragment::Type::Box &&
fragment.source.type == RichText::InlineSourceType::Widget &&
fragment.source.ptr == outerSpan ) {
boxFragment = &fragment;
}
}
ASSERT_TRUE( boxFragment != nullptr );
const RichText::InlineFragment* textFragment = nullptr;
for ( const auto& fragment : fragments ) {
if ( fragment.type == RichText::InlineFragment::Type::TextRun &&
fragment.itemPath.size() > boxFragment->itemPath.size() &&
std::equal( boxFragment->itemPath.begin(), boxFragment->itemPath.end(),
fragment.itemPath.begin() ) ) {
textFragment = &fragment;
break;
}
}
ASSERT_TRUE( textFragment != nullptr );
EXPECT_GT( boxFragment->borderWidth, 0 );
EXPECT_EQ( boxFragment->borderColor.getValue(), Color::fromString( "#ff0000" ).getValue() );
EXPECT_EQ( boxFragment->backgroundColor.getValue(), Color::fromString( "#00ff00" ).getValue() );
EXPECT_LT( boxFragment->paintBounds.Left, textFragment->bounds.Left );
EXPECT_GT( boxFragment->paintBounds.Right, textFragment->bounds.Right );
EXPECT_LT( boxFragment->paintBounds.Top, textFragment->bounds.Top );
EXPECT_GT( boxFragment->paintBounds.Bottom, textFragment->bounds.Bottom );
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineParentFontBackgroundColorIgnoresEmptyBackgroundDrawable ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="300dp" layout_height="wrap_content" font-size="18dp">
A<span id="outer">boxed</span>B
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
UI::UITextSpan* outerSpan = sceneNode->find<UI::UITextSpan>( "outer" );
ASSERT_TRUE( outerSpan != nullptr );
const Color backgroundColor = Color::fromString( "#00ff00" );
outerSpan->setFontBackgroundColor( backgroundColor );
outerSpan->setBorderWidth( 2 );
ASSERT_TRUE( outerSpan->hasBackground() );
ASSERT_EQ( outerSpan->getBackground()->getBackgroundColor().getValue(),
Color::Transparent.getValue() );
UIRichText::rebuildRichText( rt, *rt->getRichTextPtr() );
rt->getRichTextPtr()->updateLayout();
const RichText::InlineFragment* boxFragment = nullptr;
for ( const auto& fragment : rt->getRichText().getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::Box &&
fragment.source.type == RichText::InlineSourceType::Widget &&
fragment.source.ptr == outerSpan ) {
boxFragment = &fragment;
break;
}
}
ASSERT_TRUE( boxFragment != nullptr );
EXPECT_EQ( boxFragment->backgroundColor.getValue(), backgroundColor.getValue() );
EXPECT_TRUE( boxFragment->backgroundDrawable == nullptr );
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, InlineParentFontBackgroundColorUsesBorderRadiusDrawable ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
String xml = R"xml(
<RichText id="rt" layout_width="300dp" layout_height="wrap_content" font-size="18dp">
A<span id="outer">boxed</span>B
</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
UI::UITextSpan* outerSpan = sceneNode->find<UI::UITextSpan>( "outer" );
ASSERT_TRUE( outerSpan != nullptr );
const Color backgroundColor = Color::fromString( "#b45f38" );
outerSpan->setFontBackgroundColor( backgroundColor );
outerSpan->setTopLeftRadius( "2px" );
outerSpan->setTopRightRadius( "2px" );
outerSpan->setBottomLeftRadius( "2px" );
outerSpan->setBottomRightRadius( "2px" );
UIRichText::rebuildRichText( rt, *rt->getRichTextPtr() );
rt->getRichTextPtr()->updateLayout();
const RichText::InlineFragment* boxFragment = nullptr;
for ( const auto& fragment : rt->getRichText().getInlineFragments() ) {
if ( fragment.type == RichText::InlineFragment::Type::Box &&
fragment.source.type == RichText::InlineSourceType::Widget &&
fragment.source.ptr == outerSpan ) {
boxFragment = &fragment;
break;
}
}
ASSERT_TRUE( boxFragment != nullptr );
ASSERT_TRUE( boxFragment->backgroundDrawable != nullptr );
EXPECT_TRUE( boxFragment->backgroundDrawableUsesFragmentColor );
EXPECT_EQ( boxFragment->backgroundColor.getValue(), backgroundColor.getValue() );
EXPECT_EQ( boxFragment->backgroundDrawable->getDrawableType(), Drawable::UIBACKGROUNDDRAWABLE );
auto* background = static_cast<UIBackgroundDrawable*>( boxFragment->backgroundDrawable );
EXPECT_TRUE( background->hasRadius() );
EXPECT_NEAR( background->getRadiuses().topLeft.x, 2.f, 0.001f );
EXPECT_NEAR( background->getRadiuses().topRight.x, 2.f, 0.001f );
EXPECT_NEAR( background->getRadiuses().bottomLeft.x, 2.f, 0.001f );
EXPECT_NEAR( background->getRadiuses().bottomRight.x, 2.f, 0.001f );
destroyRichTextScene( sceneNode );
}
UTEST( UIRichText, DefaultStyleInheritance ) {
auto sceneNode = createRichTextScene();
ASSERT_TRUE( sceneNode != nullptr );
@@ -600,20 +1356,23 @@ UTEST( UIRichText, DefaultStyleInheritance ) {
sceneNode->update( Time::Zero );
auto graphicsRt = rt->getRichText();
const auto& blocks = graphicsRt.getBlocks();
const auto& lines = graphicsRt.getLines();
// blocks[0] should be "Default size" with parent's size and color
// blocks[1] should be "Small" with overridden size and color
ASSERT_TRUE( blocks.size() >= 2 );
// spans[0] should be "Default size" with parent's size and color
// spans[1] should be "Small" with overridden size and color
ASSERT_FALSE( lines.empty() );
ASSERT_TRUE( lines.front().spans.size() >= 2 );
EXPECT_TRUE( std::holds_alternative<RichText::SpanBlock>( blocks[0] ) );
auto text0 = std::get<RichText::SpanBlock>( blocks[0] ).text;
ASSERT_EQ( lines.front().spans[0].type, RichText::RenderSpan::Type::Text );
auto text0 = lines.front().spans[0].text;
ASSERT_TRUE( text0 != nullptr );
EXPECT_EQ( text0->getCharacterSize(), rt->getFontSize() );
EXPECT_EQ( text0->getFillColor().getValue(), rt->getFontColor().getValue() );
EXPECT_EQ( text0->getFillColor().getValue(), Color::fromString( "#FF0000" ).getValue() );
EXPECT_TRUE( std::holds_alternative<RichText::SpanBlock>( blocks[1] ) );
auto text1 = std::get<RichText::SpanBlock>( blocks[1] ).text;
ASSERT_EQ( lines.front().spans[1].type, RichText::RenderSpan::Type::Text );
auto text1 = lines.front().spans[1].text;
ASSERT_TRUE( text1 != nullptr );
EXPECT_EQ( text1->getCharacterSize(), (unsigned int)PixelDensity::dpToPxI( 16 ) );
EXPECT_EQ( text1->getFillColor().getValue(), Color::fromString( "#00FF00" ).getValue() );

View File

@@ -658,12 +658,14 @@ UTEST( UITextNode_RichTextRebuild, MixedContentAppearsInRichText ) {
UIRichText* rt = sceneNode->find<UIRichText>( "rt" );
ASSERT_TRUE( rt != nullptr );
int textBlocks = 0;
for ( const auto& block : rt->getRichTextPtr()->getBlocks() ) {
if ( std::holds_alternative<RichText::SpanBlock>( block ) )
textBlocks++;
int textSpans = 0;
for ( const auto& line : rt->getRichTextPtr()->getLines() ) {
for ( const auto& span : line.spans ) {
if ( span.type == RichText::RenderSpan::Type::Text )
textSpans++;
}
}
EXPECT_GE( textBlocks, 3 ); // "before ", "bold", " after"
EXPECT_GE( textSpans, 3 ); // "before ", "bold", " after"
destroyRichTextScene( sceneNode );
}