mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-05-28 17:16:29 +03:00
Huge RichText refactor to improve inline-box support. Still WIP.
This commit is contained in:
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 743–751):
|
||||
|
||||
```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 599–607):
|
||||
```
|
||||
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 686–702) 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 420–426), 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 686–702 (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 716–717) 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 |
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
943
.agent/plans/ui_inline_formatting_context_plan.md
Normal file
943
.agent/plans/ui_inline_formatting_context_plan.md
Normal 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.
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -219,6 +219,10 @@ UIBackgroundDrawable& UINodeDrawable::getBackgroundDrawable() {
|
||||
return mBackgroundColor;
|
||||
}
|
||||
|
||||
bool UINodeDrawable::hasDrawableLayers() const {
|
||||
return !mGroup.empty();
|
||||
}
|
||||
|
||||
bool UINodeDrawable::isSmooth() const {
|
||||
return mSmooth;
|
||||
}
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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() );
|
||||
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user