diff --git a/.agent/plans/border_box_model_plan.md b/.agent/plans/border_box_model_plan.md new file mode 100644 index 000000000..6fcee76ef --- /dev/null +++ b/.agent/plans/border_box_model_plan.md @@ -0,0 +1,286 @@ +# 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()->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//.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). diff --git a/.agent/plans/inline_svg_support_plan.md b/.agent/plans/inline_svg_support_plan.md new file mode 100644 index 000000000..130699929 --- /dev/null +++ b/.agent/plans/inline_svg_support_plan.md @@ -0,0 +1,555 @@ +# Inline SVG Support & HTML Image Element Analysis Plan + +This document outlines the architectural plan for adding inline `` HTML element support and analyzes whether a dedicated `UIHTMLImage` class is needed to improve `` 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 `` Today + +``` +HTML: + → 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 `` Inline Elements Fail Today + +1. `UIWidgetCreator` has no `"svg"` registration (line 164 of widgetcreator.cpp) +2. When the HTML parser encounters `...`, it calls `createFromName("svg")` → returns `nullptr` → silently skipped +3. Even if a widget were created, the SVG's **children** (``, ``, ``, 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 `` + +--- + +## 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 + +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 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( 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 ``, ``, `` 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 `...` 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:** `` +2. **SVG with viewBox:** `` +3. **CSS sizing on SVG:** `...` +4. **SVG with xmlns:** `...` +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 `` 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 `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. diff --git a/.agent/plans/layout_separation_float_plan.md b/.agent/plans/layout_separation_float_plan.md new file mode 100644 index 000000000..b655ee38d --- /dev/null +++ b/.agent/plans/layout_separation_float_plan.md @@ -0,0 +1,61 @@ +# 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 leftFloats; std::vector 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) diff --git a/.agent/plans/layout_separation_form_plan.md b/.agent/plans/layout_separation_form_plan.md new file mode 100644 index 000000000..cb16df768 --- /dev/null +++ b/.agent/plans/layout_separation_form_plan.md @@ -0,0 +1,55 @@ +# UI Layout Phase 8: Form Action and Navigation Plan + +This document outlines the architectural plan for implementing HTML `
` 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 extraHeaders; + }; + ``` +- Add `void navigate( const NavigationRequest& request );` to `UISceneNode`. +- Add `void setNavigationInterceptorCb( std::function 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) diff --git a/.agent/rules/html-layout-architecture.md b/.agent/rules/html-layout-architecture.md new file mode 100644 index 000000000..b4d4d6c6b --- /dev/null +++ b/.agent/rules/html-layout-architecture.md @@ -0,0 +1,33 @@ +# HTML Layout Architecture + +This document describes the decoupled HTML/CSS layout engine architecture implemented in `eepp` for `UIHTMLWidget` and related classes. + +## Core Concepts + +### 1. UIHTMLWidget +`UIHTMLWidget` is the base class for all HTML-like elements. It holds parsed CSS properties (Display, Position, Float, Clear, etc.). Instead of implementing complex layout math directly, it queries a `UILayouterManager` to instantiate the appropriate `UILayouter` based on its `CSSDisplay` property. + +### 2. Layouters +Layout math has been extracted from widgets into stateless (or locally stateful) "Layouters": +- **BlockLayouter:** Handles `CSSDisplay::Block`. It positions block-level children vertically. For rich text, it delegates text shaping to the `RichText` engine and simply maps physical coordinates for custom inline widgets. +- **TableLayouter:** Handles `CSSDisplay::Table`. Encapsulates HTML table column width distribution and row positioning. +- **InlineLayouter:** Handles `CSSDisplay::Inline`. *This layouter is empty by design.* Inline formatting (like `` or ``) is completely managed by the nearest Block container (via the `RichText` engine). It acts as a no-op so standard linear layout logic doesn't override text flows. +- **NoneLayouter:** Handles `CSSDisplay::None`. Skips all layout and rendering. + +### 3. The UIRichText Engine Integration +`UIRichText` acts as the primary block container for mixed text and widget content. +- It uses `rebuildRichText()` to recursively traverse its children. +- Pure text nodes (`UITextSpan`, `
`) are appended to the core `RichText` engine via `RichText::addSpan()`. +- Arbitrary inline widgets (e.g., ``, `