From 24cf3ad6c754a4dd507b1df138ada6d27fb937f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 27 Apr 2026 16:41:37 -0300 Subject: [PATCH] Added UISvg (for embedded svg in XML) and UIHTMLImage (to handle HTML `img`, although still WIP). --- .agent/plans/inline_svg_support_plan.md | 555 ++++++++++++++++++++++++ include/eepp/ui/uihelper.hpp | 2 + include/eepp/ui/uihtmlimage.hpp | 34 ++ include/eepp/ui/uisvg.hpp | 38 ++ src/eepp/ui/uihtmlimage.cpp | 82 ++++ src/eepp/ui/uisvg.cpp | 117 +++++ src/eepp/ui/uiwidget.cpp | 36 +- src/eepp/ui/uiwidgetcreator.cpp | 9 +- src/tests/unit_tests/htmlsvg.cpp | 402 +++++++++++++++++ 9 files changed, 1260 insertions(+), 15 deletions(-) create mode 100644 .agent/plans/inline_svg_support_plan.md create mode 100644 include/eepp/ui/uihtmlimage.hpp create mode 100644 include/eepp/ui/uisvg.hpp create mode 100644 src/eepp/ui/uihtmlimage.cpp create mode 100644 src/eepp/ui/uisvg.cpp create mode 100644 src/tests/unit_tests/htmlsvg.cpp 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/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 9c2ff2ae3..1be2a62ad 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -129,6 +129,8 @@ enum UINodeType { UI_TYPE_HTML_HTML, UI_TYPE_HTML_BODY, UI_TYPE_HTML_LIST_ITEM, + UI_TYPE_HTML_IMAGE, + UI_TYPE_SVG, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uihtmlimage.hpp b/include/eepp/ui/uihtmlimage.hpp new file mode 100644 index 000000000..74dda138d --- /dev/null +++ b/include/eepp/ui/uihtmlimage.hpp @@ -0,0 +1,34 @@ +#ifndef EE_UI_UIHTMLIMAGE_HPP +#define EE_UI_UIHTMLIMAGE_HPP + +#include + +namespace EE { namespace UI { + +class EE_API UIHTMLImage : public UIImage { + public: + static UIHTMLImage* New(); + + virtual ~UIHTMLImage(); + + 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; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uisvg.hpp b/include/eepp/ui/uisvg.hpp new file mode 100644 index 000000000..556b9e2c9 --- /dev/null +++ b/include/eepp/ui/uisvg.hpp @@ -0,0 +1,38 @@ +#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(); + + std::string mSvgXml; + Uint64 mTaskId{ 0 }; + + void loadSvgXml( const pugi::xml_node& node ); + void scheduleRasterize(); + void rasterizeSvg( const std::string& svgXml ); + void clearThreadTag(); +}; + +}} // namespace EE::UI + +#endif diff --git a/src/eepp/ui/uihtmlimage.cpp b/src/eepp/ui/uihtmlimage.cpp new file mode 100644 index 000000000..75f407b64 --- /dev/null +++ b/src/eepp/ui/uihtmlimage.cpp @@ -0,0 +1,82 @@ +#include + +#define PUGIXML_HEADER_ONLY +#include + +#include +#include +#include +#include + +namespace EE { namespace UI { + +UIHTMLImage* UIHTMLImage::New() { + return eeNew( UIHTMLImage, () ); +} + +UIHTMLImage::UIHTMLImage() : UIImage( "img" ) {} + +UIHTMLImage::~UIHTMLImage() {} + +Uint32 UIHTMLImage::getType() const { + return UI_TYPE_HTML_IMAGE; +} + +bool UIHTMLImage::isType( const Uint32& type ) const { + return UIHTMLImage::getType() == type ? true : UIImage::isType( type ); +} + +void UIHTMLImage::loadFromXmlNode( const pugi::xml_node& node ) { + for ( auto& attr : node.attributes() ) { + if ( String::iequals( attr.name(), "alt" ) ) { + mAlt = attr.value(); + break; + } + } + + beginAttributesTransaction(); + UIWidget::loadFromXmlNode( node ); + endAttributesTransaction(); +} + +void UIHTMLImage::draw() { + if ( mVisible && NULL != mDrawable && 0.f != mAlpha ) { + UIImage::draw(); + } else if ( mVisible && 0.f != mAlpha && !mAlt.empty() ) { + UINode::draw(); + + auto* themeManager = getUISceneNode()->getUIThemeManager(); + FontStyleConfig fontStyleConfig; + fontStyleConfig.Font = themeManager->getDefaultFont(); + fontStyleConfig.CharacterSize = themeManager->getDefaultFontSize(); + fontStyleConfig.FontColor = Color( 128, 128, 128, mAlpha ); + + Float textWidth = Text::getTextWidth( mAlt, fontStyleConfig ); + Float availableWidth = mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right; + Float x = mScreenPos.x + mPaddingPx.Left; + Float y = mScreenPos.y + mPaddingPx.Top; + + if ( textWidth < availableWidth ) + x += ( availableWidth - textWidth ) / 2; + + y += ( mSize.getHeight() - mPaddingPx.Top - mPaddingPx.Bottom - + PixelDensity::getPixelDensity() * fontStyleConfig.CharacterSize ) / + 2; + + Text::draw( String( mAlt ), Vector2f( x, y ), fontStyleConfig ); + } +} + +const std::string& UIHTMLImage::getAlt() const { + return mAlt; +} + +UIHTMLImage* UIHTMLImage::setAlt( const std::string& alt ) { + if ( mAlt != alt ) { + mAlt = alt; + invalidateDraw(); + } + return this; +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uisvg.cpp b/src/eepp/ui/uisvg.cpp new file mode 100644 index 000000000..38acac91e --- /dev/null +++ b/src/eepp/ui/uisvg.cpp @@ -0,0 +1,117 @@ +#include + +#define PUGIXML_HEADER_ONLY +#include + +#include +#include +#include +#include + +namespace EE { namespace UI { + +namespace { + +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 ); + } +}; + +} // namespace + +UISvg* UISvg::New() { + return eeNew( UISvg, () ); +} + +UISvg::UISvg() : UIImage( "svg" ) { + mFlags |= UI_LOADS_ITS_CHILDREN; +} + +UISvg::~UISvg() { + clearThreadTag(); +} + +Uint32 UISvg::getType() const { + return UI_TYPE_SVG; +} + +bool UISvg::isType( const Uint32& type ) const { + return UISvg::getType() == type ? true : UIImage::isType( type ); +} + +void UISvg::loadFromXmlNode( const pugi::xml_node& node ) { + beginAttributesTransaction(); + UIWidget::loadFromXmlNode( node ); + endAttributesTransaction(); + + loadSvgXml( node ); + + scheduleRasterize(); +} + +void UISvg::loadSvgXml( const pugi::xml_node& node ) { + XmlStringWriter writer; + node.print( writer ); + mSvgXml = writer.result; +} + +void UISvg::scheduleRasterize() { + if ( mSvgXml.empty() ) + return; + + auto size = getPixelsSize(); + if ( size.getWidth() <= 0.f || size.getHeight() <= 0.f ) + return; + + if ( !getUISceneNode()->hasThreadPool() ) { + rasterizeSvg( mSvgXml ); + return; + } + + clearThreadTag(); + + std::string svgXml( mSvgXml ); + mTaskId = getUISceneNode()->getThreadPool()->run( + [this, svgXml = std::move( svgXml )] { rasterizeSvg( svgXml ); }, {}, (Uint64)this ); +} + +void UISvg::rasterizeSvg( const std::string& svgXml ) { + Texture* texture = TextureFactory::instance()->loadFromMemory( + (const unsigned char*)svgXml.data(), svgXml.size() ); + + if ( !texture ) + return; + + Sprite* sprite = Sprite::New(); + sprite->createStatic( texture ); + sprite->setAsTextureOwner( true ); + sprite->setAsTextureRegionOwner( true ); + + runOnMainThread( [this, sprite] { setDrawable( sprite, true ); } ); +} + +void UISvg::onSizeChange() { + UIImage::onSizeChange(); + + auto size = getPixelsSize(); + if ( size.getWidth() <= 0.f || size.getHeight() <= 0.f || mSvgXml.empty() ) + return; + + debounce( [this] { scheduleRasterize(); }, Milliseconds( 150 ), (UintPtr)this ); +} + +void UISvg::clearThreadTag() { + if ( mTaskId != 0 && getUISceneNode()->hasThreadPool() ) { + getUISceneNode()->getThreadPool()->removeWithTag( (Uint64)this ); + mTaskId = 0; + } +} + +const std::string& UISvg::getSvgXml() const { + return mSvgXml; +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index 11c6a7f0a..46c5c3960 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -1837,24 +1837,32 @@ bool UIWidget::applyProperty( const StyleSheetProperty& attribute ) { notifyLayoutAttrChange(); break; case PropertyId::Width: - if ( mStyle ) { - mStyle->setStyleSheetProperty( - StyleSheetProperty( "layout-width", attribute.value(), true, - StyleSheetSelectorRule::SpecificityImportant ) ); + if ( attribute.value() == "auto" ) { + setLayoutWidthPolicy( SizePolicy::WrapContent ); + } else { + if ( mStyle ) { + mStyle->setStyleSheetProperty( + StyleSheetProperty( "layout-width", attribute.value(), true, + StyleSheetSelectorRule::SpecificityImportant ) ); + } + setLayoutWidthPolicy( SizePolicy::Fixed ); + setSize( eefloor( lengthFromValueAsDp( attribute ) ), getSize().getHeight() ); + notifyLayoutAttrChange(); } - setLayoutWidthPolicy( SizePolicy::Fixed ); - setSize( eefloor( lengthFromValueAsDp( attribute ) ), getSize().getHeight() ); - notifyLayoutAttrChange(); break; case PropertyId::Height: - if ( mStyle ) { - mStyle->setStyleSheetProperty( - StyleSheetProperty( "layout-height", attribute.value(), true, - StyleSheetSelectorRule::SpecificityImportant ) ); + if ( attribute.value() == "auto" ) { + setLayoutHeightPolicy( SizePolicy::WrapContent ); + } else { + if ( mStyle ) { + mStyle->setStyleSheetProperty( + StyleSheetProperty( "layout-height", attribute.value(), true, + StyleSheetSelectorRule::SpecificityImportant ) ); + } + setLayoutHeightPolicy( SizePolicy::Fixed ); + setSize( getSize().getWidth(), eefloor( lengthFromValueAsDp( attribute ) ) ); + notifyLayoutAttrChange(); } - setLayoutHeightPolicy( SizePolicy::Fixed ); - setSize( getSize().getWidth(), eefloor( lengthFromValueAsDp( attribute ) ) ); - notifyLayoutAttrChange(); break; case PropertyId::BackgroundColor: setBackgroundColor( attribute.asColor() ); diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index b0afb6221..0437f8bd6 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -162,10 +164,15 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["li"] = UIHTMLListItem::New; registeredWidget["pre"] = UIRichText::NewPre; registeredWidget["img"] = [] { - auto img = UIImage::NewWithTag( "img" ); + auto img = UIHTMLImage::New(); img->setFlags( UI_HTML_ELEMENT ); return img; }; + registeredWidget["svg"] = [] { + auto svg = UISvg::New(); + svg->setFlags( UI_HTML_ELEMENT ); + return svg; + }; registeredWidget["input"] = [] { return HTMLInput::New(); }; registeredWidget["header"] = [] { return UIRichText::NewWithTag( "header" ); }; registeredWidget["article"] = [] { return UIRichText::NewWithTag( "article" ); }; diff --git a/src/tests/unit_tests/htmlsvg.cpp b/src/tests/unit_tests/htmlsvg.cpp new file mode 100644 index 000000000..3f79d708f --- /dev/null +++ b/src/tests/unit_tests/htmlsvg.cpp @@ -0,0 +1,402 @@ +#include "utest.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Window; +using namespace EE::Scene; +using namespace EE::UI; +using namespace EE::UI::Tools; + +static UI::UISceneNode* createScene() { + Engine::instance()->createWindow( WindowSettings( 800, 600, "SVG 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" ); + if ( !font->loaded() ) { + Engine::destroySingleton(); + return nullptr; + } + FontFamily::loadFromRegular( font ); + + UI::UISceneNode* sceneNode = UI::UISceneNode::New(); + UI::UIThemeManager* themeManager = sceneNode->getUIThemeManager(); + themeManager->setDefaultFont( font ); + + return sceneNode; +} + +static void destroyScene( UI::UISceneNode* sceneNode ) { + eeDelete( sceneNode ); + Engine::destroySingleton(); +} + +static UISvg* findSvgWidget( UIWidget* root ) { + return root->findByType( UI_TYPE_SVG ); +} + +UTEST( UISvg, basicInlineSvg ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_TRUE( svgWidget->isType( UI_TYPE_SVG ) ); + EXPECT_FALSE( svgWidget->getSvgXml().empty() ); + EXPECT_GT( svgWidget->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( svgWidget->getPixelsSize().getHeight(), 0.f ); + + destroyScene( sceneNode ); +} + +UTEST( UISvg, svgWithViewBox ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_FALSE( svgWidget->getSvgXml().empty() ); + EXPECT_GT( svgWidget->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( svgWidget->getPixelsSize().getHeight(), 0.f ); + + destroyScene( sceneNode ); +} + +UTEST( UISvg, svgWithXmlns ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_EQ( svgWidget->getType(), UI_TYPE_SVG ); + + destroyScene( sceneNode ); +} + +UTEST( UISvg, svgChildrenNotCreatedAsUiWidgets ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + +
+ + + + + +
+ +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_TRUE( svgWidget->loadsItsChildren() ); + EXPECT_TRUE( svgWidget->isType( UI_TYPE_SVG ) ); + + // SVG's child count should be 0 because we set UI_LOADS_ITS_CHILDREN + // which prevents the parent from creating widgets for SVG's internal elements + EXPECT_EQ( svgWidget->getChildCount(), 0u ); + + destroyScene( sceneNode ); +} + +UTEST( UISvg, multipleSvgElements ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidgets = rootWidget->findAllByTag( "svg" ); + EXPECT_EQ( svgWidgets.size(), 2u ); + + for ( auto* w : svgWidgets ) { + EXPECT_TRUE( w->isType( UI_TYPE_SVG ) ); + EXPECT_FALSE( static_cast( w )->getSvgXml().empty() ); + } + + destroyScene( sceneNode ); +} + +UTEST( UISvg, svgWithNoDimensions ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_FALSE( svgWidget->getSvgXml().empty() ); + EXPECT_TRUE( svgWidget->isType( UI_TYPE_SVG ) ); + + destroyScene( sceneNode ); +} + +UTEST( UISvg, svgInsideBlockElement ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + +
+

Before SVG

+ +

After SVG

+
+ +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_TRUE( svgWidget->isType( UI_TYPE_SVG ) ); + EXPECT_FALSE( svgWidget->getSvgXml().empty() ); + + destroyScene( sceneNode ); +} + +UTEST( UISvg, svgWithMemoryAsset ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + EXPECT_TRUE( svgWidget->isType( UI_TYPE_SVG ) ); + + std::string xml = svgWidget->getSvgXml(); + EXPECT_FALSE( xml.empty() ); + EXPECT_TRUE( xml.find( " + + +Test Image + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto imgWidget = rootWidget->findByType( UI_TYPE_HTML_IMAGE ); + ASSERT_TRUE( imgWidget != nullptr ); + EXPECT_TRUE( imgWidget->isType( UI_TYPE_HTML_IMAGE ) ); + EXPECT_STREQ( imgWidget->getElementTag().c_str(), "img" ); + + destroyScene( sceneNode ); +} + +UTEST( UIHTMLImage, altAttributeCaptured ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + +My broken image + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto imgWidget = rootWidget->findByType( UI_TYPE_HTML_IMAGE ); + ASSERT_TRUE( imgWidget != nullptr ); + EXPECT_STREQ( imgWidget->getAlt().c_str(), "My broken image" ); + + destroyScene( sceneNode ); +} + +UTEST( UIHTMLImage, noAltAttributeEmpty ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + + + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto imgWidget = rootWidget->findByType( UI_TYPE_HTML_IMAGE ); + ASSERT_TRUE( imgWidget != nullptr ); + EXPECT_TRUE( imgWidget->getAlt().empty() ); + + destroyScene( sceneNode ); +} + +UTEST( UIHTMLImage, multipleImgElements ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + +First +Second +Third + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto imgWidgets = rootWidget->findAllByTag( "img" ); + EXPECT_EQ( imgWidgets.size(), 3u ); + + for ( auto* w : imgWidgets ) + EXPECT_TRUE( w->isType( UI_TYPE_HTML_IMAGE ) ); + + destroyScene( sceneNode ); +} + +UTEST( UIHTMLImage, imgAndSvgTogether ) { + auto sceneNode = createScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + std::string html = R"html( + + +

Before

+An image + +Another image + +)html"; + + auto rootWidget = sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + ASSERT_TRUE( rootWidget != nullptr ); + + sceneNode->update( Time::Zero ); + + auto svgWidget = findSvgWidget( rootWidget ); + ASSERT_TRUE( svgWidget != nullptr ); + + auto imgWidget1 = rootWidget->find( "img1" ); + ASSERT_TRUE( imgWidget1 != nullptr ); + EXPECT_STREQ( imgWidget1->getAlt().c_str(), "An image" ); + + auto imgWidget2 = rootWidget->find( "img2" ); + ASSERT_TRUE( imgWidget2 != nullptr ); + EXPECT_STREQ( imgWidget2->getAlt().c_str(), "Another image" ); + + destroyScene( sceneNode ); +}