diff --git a/.agent/plans/html_background_properties_plan.md b/.agent/plans/html_background_properties_plan.md new file mode 100644 index 000000000..7279bc10d --- /dev/null +++ b/.agent/plans/html_background_properties_plan.md @@ -0,0 +1,813 @@ +# 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` | `` | `transparent` | No | +| 2 | `background-image` | `none \| [, ]*` | `none` | Yes | +| 3 | `background-position` | ` [, ]*` | `0% 0%` | Yes | +| 4 | `background-size` | `auto \| cover \| contain \| {1,2}` | `auto` | Yes | +| 5 | `background-repeat` | ` [, ]*` | `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) | ` [, ]* ` | — | Yes | + +**Repeat-style values:** `repeat-x | repeat-y | [repeat | space | round | no-repeat]{1,2}` + +**Position syntax:** ` = [left | center | right | ] || [top | center | bottom | ] | [left | center | right | ] [top | center | bottom | ] | [center | [left | right] ?] && [center | [top | bottom] ?]` + +**Shorthand syntax:** `[ , ]* ` where each ` = || [ / ]? || || || || ` and ` = <'background-color'> || || [ / ]? || || || || ` + +--- + +## 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` (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: [ || [/]? || || || || ]# +``` + +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: " + ``` + 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: ` + +### 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 + +
+
+``` + +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. diff --git a/.agent/plans/html_whitespace_collapsing_plan.md b/.agent/plans/html_whitespace_collapsing_plan.md new file mode 100644 index 000000000..f2669bba3 --- /dev/null +++ b/.agent/plans/html_whitespace_collapsing_plan.md @@ -0,0 +1,516 @@ +# 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 ` + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + diff --git a/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp b/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp new file mode 100644 index 000000000..86cad9369 Binary files /dev/null and b/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-background-atlas.webp b/bin/unit_tests/assets/html/eepp-ui-background-atlas.webp new file mode 100644 index 000000000..9df703067 Binary files /dev/null and b/bin/unit_tests/assets/html/eepp-ui-background-atlas.webp differ diff --git a/include/eepp/ui/css/propertydefinition.hpp b/include/eepp/ui/css/propertydefinition.hpp index 2cc840c5f..f60eddee0 100644 --- a/include/eepp/ui/css/propertydefinition.hpp +++ b/include/eepp/ui/css/propertydefinition.hpp @@ -20,6 +20,9 @@ enum class PropertyId : Uint32 { BackgroundPositionY = String::hash( "background-position-y" ), BackgroundRepeat = String::hash( "background-repeat" ), BackgroundSize = String::hash( "background-size" ), + BackgroundOrigin = String::hash( "background-origin" ), + BackgroundClip = String::hash( "background-clip" ), + BackgroundAttachment = String::hash( "background-attachment" ), ForegroundColor = String::hash( "foreground-color" ), ForegroundTint = String::hash( "foreground-tint" ), ForegroundImage = String::hash( "foreground-image" ), diff --git a/include/eepp/ui/tools/htmlformatter.hpp b/include/eepp/ui/tools/htmlformatter.hpp index 528aa6e08..f3760d933 100644 --- a/include/eepp/ui/tools/htmlformatter.hpp +++ b/include/eepp/ui/tools/htmlformatter.hpp @@ -2,26 +2,12 @@ #define EE_UI_TOOLS_HTMLFORMATTER_HPP #include -#include - -namespace pugi { -class xml_node; -} +#include namespace EE { namespace UI { namespace Tools { class EE_API HTMLFormatter { public: - static bool isInlineNode( const pugi::xml_node& node ); - - static bool hasSignificantText( const pugi::xml_node& node ); - - static pugi::xml_node getLogicalPrev( const pugi::xml_node& node ); - - static pugi::xml_node getLogicalNext( const pugi::xml_node& node ); - - static String collapseXmlWhitespace( const String& text, const pugi::xml_node& node ); - static std::string HTMLtoXML( const std::string& layoutString ); }; diff --git a/include/eepp/ui/uinode.hpp b/include/eepp/ui/uinode.hpp index 30c8061cb..ae4abcd00 100644 --- a/include/eepp/ui/uinode.hpp +++ b/include/eepp/ui/uinode.hpp @@ -416,6 +416,12 @@ class EE_API UINode : public Node { */ UINode* setBackgroundSize( const std::string& size, int index = 0 ); + UINode* setBackgroundOrigin( const std::string& origin, int index = 0 ); + + UINode* setBackgroundClip( const std::string& clip, int index = 0 ); + + UINode* setBackgroundAttachment( const std::string& attachment, int index = 0 ); + /** * @brief Gets the background color. * diff --git a/include/eepp/ui/uinodedrawable.hpp b/include/eepp/ui/uinodedrawable.hpp index 525b1894b..2370d8a8f 100644 --- a/include/eepp/ui/uinodedrawable.hpp +++ b/include/eepp/ui/uinodedrawable.hpp @@ -14,14 +14,25 @@ namespace EE { namespace UI { class UINode; +enum class BackgroundMode { Native, Html }; + class EE_API UINodeDrawable : public Drawable { public: - enum Repeat { RepeatXY, RepeatX, RepeatY, NoRepeat }; + enum class RepeatX { NoRepeat, Repeat, Space, Round }; + enum class RepeatY { NoRepeat, Repeat, Space, Round }; - static Repeat repeatFromText( const std::string& text ); + static void repeatFromText( const std::string& text, RepeatX& repeatX, RepeatY& repeatY ); class EE_API LayerDrawable : public Drawable { public: + 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 ); + static LayerDrawable* New( UINodeDrawable* container ); LayerDrawable( UINodeDrawable* container ); @@ -62,9 +73,13 @@ class EE_API UINodeDrawable : public Drawable { const std::string& getSizeEq() const; - const Repeat& getRepeat() const; + RepeatX getRepeatX() const; - void setRepeat( const Repeat& repeat ); + RepeatY getRepeatY() const; + + void setRepeatX( RepeatX repeatX ); + + void setRepeatY( RepeatY repeatY ); void invalidate(); @@ -84,6 +99,24 @@ class EE_API UINodeDrawable : public Drawable { void setPositionY( const std::string& positionY ); + 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; + + const std::string& getOriginEq() const; + + const std::string& getClipEq() const; + + const std::string& getAttachmentEq() const; + protected: UINodeDrawable* mContainer; Sizef mSize; @@ -98,7 +131,14 @@ class EE_API UINodeDrawable : public Drawable { Drawable* mDrawable; std::string mDrawableRef; Uint32 mResourceChangeCbId; - Repeat mRepeat; + RepeatX mRepeatX{ RepeatX::NoRepeat }; + RepeatY mRepeatY{ RepeatY::NoRepeat }; + 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 }; virtual void onPositionChange(); @@ -153,6 +193,12 @@ class EE_API UINodeDrawable : public Drawable { void setDrawableSize( int index, const std::string& sizeEq ); + void setDrawableOrigin( int index, const std::string& origin ); + + void setDrawableClip( int index, const std::string& clip ); + + void setDrawableAttachment( int index, const std::string& attachment ); + void setDrawableColor( int index, const Color& color ); void setBackgroundColor( const Color& color ); @@ -173,6 +219,10 @@ class EE_API UINodeDrawable : public Drawable { void setSmooth( bool smooth ); + void setBackgroundMode( BackgroundMode mode ); + + BackgroundMode getBackgroundMode() const; + protected: UINode* mOwner; UIBackgroundDrawable mBackgroundColor; @@ -181,6 +231,7 @@ class EE_API UINodeDrawable : public Drawable { bool mNeedsUpdate{ true }; bool mClipEnabled{ false }; bool mSmooth{ false }; + BackgroundMode mBackgroundMode{ BackgroundMode::Native }; virtual void onPositionChange(); diff --git a/include/eepp/ui/uirichtext.hpp b/include/eepp/ui/uirichtext.hpp index ee05ae535..59e6b07d4 100644 --- a/include/eepp/ui/uirichtext.hpp +++ b/include/eepp/ui/uirichtext.hpp @@ -11,6 +11,8 @@ class EE_API UIRichText : public UIHTMLWidget { public: enum class IntrinsicMode { None, Min, Max }; + static String collapseInternalWhitespace( const String& s ); + static void rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode = IntrinsicMode::None ); diff --git a/include/eepp/ui/uitextnode.hpp b/include/eepp/ui/uitextnode.hpp index eb3763e14..e8405c521 100644 --- a/include/eepp/ui/uitextnode.hpp +++ b/include/eepp/ui/uitextnode.hpp @@ -26,8 +26,15 @@ class EE_API UITextNode : public UIWidget { void setText( const String& text ); + bool isWhitespaceOnly() const; + + size_t getLayoutCharCount() const { return mLayoutCharCount; } + + void setLayoutCharCount( size_t count ) { mLayoutCharCount = count; } + protected: String mText; + size_t mLayoutCharCount{ 0 }; }; }} // namespace EE::UI diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index 910be3bb3..e7102619a 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -803,6 +803,17 @@ class EE_API UIWidget : public UINode { /** @return True if the widget is not a text node. */ bool isWidgetElement() const; + /** + * @brief Returns whether this widget participates in inline formatting (for whitespace + * collapsing). + * + * Inline display types (CSSDisplay::Inline, CSSDisplay::InlineBlock) and text nodes + * are considered inline. All other widgets default to block. + * + * @return True if this widget is inline-level. + */ + bool isInlineDisplay() const; + /** @return The index of this element among its sibling elements. */ Uint32 getElementIndex() const; diff --git a/src/eepp/graphics/drawablesearcher.cpp b/src/eepp/graphics/drawablesearcher.cpp index 0485ea5c0..7bb4ad7ba 100644 --- a/src/eepp/graphics/drawablesearcher.cpp +++ b/src/eepp/graphics/drawablesearcher.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -153,6 +154,8 @@ Drawable* DrawableSearcher::searchByName( const std::string& name, bool firstSea } #endif + FileSystem::filePathRemoveProcessPath( filePath ); + drawable = TextureFactory::instance()->getByName( filePath ); if ( NULL == drawable ) { diff --git a/src/eepp/ui/blocklayouter.cpp b/src/eepp/ui/blocklayouter.cpp index 6b7d3e381..c7c37f89d 100644 --- a/src/eepp/ui/blocklayouter.cpp +++ b/src/eepp/ui/blocklayouter.cpp @@ -175,10 +175,11 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { return bounds; // UITextNode is a logical marker; its text is rendered by the - // RichText engine — just advance the character index and return + // RichText engine just advance the character index and return // empty bounds so it does not affect any widget's geometry. if ( node->isTextNode() ) { - curCharIdx += static_cast( node )->getText().length(); + auto* tn = static_cast( node ); + curCharIdx += tn->getLayoutCharCount(); return bounds; } diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index 620931f4c..8d322d526 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -111,6 +111,9 @@ void StyleSheetSpecification::registerDefaultProperties() { registerProperty( "background-size", "auto" ) .setType( PropertyType::BackgroundSize ) .setIndexed(); + registerProperty( "background-origin", "padding-box" ).setIndexed(); + registerProperty( "background-clip", "border-box" ).setIndexed(); + registerProperty( "background-attachment", "scroll" ).setIndexed(); registerProperty( "foreground-color", "" ).setType( PropertyType::Color ); registerProperty( "foreground-image", "none" ).setIndexed(); registerProperty( "foreground-tint", "" ).setIndexed().setType( PropertyType::Color ); @@ -496,10 +499,11 @@ void StyleSheetSpecification::registerDefaultProperties() { { "margin-top", "margin-right", "margin-bottom", "margin-left" }, "box" ); registerShorthand( "padding", { "padding-top", "padding-right", "padding-bottom", "padding-left" }, "box" ); - registerShorthand( - "background", - { "background-color", "background-image", "background-repeat", "background-position" }, - "background" ); + registerShorthand( "background", + { "background-color", "background-image", "background-position", + "background-size", "background-repeat", "background-attachment", + "background-origin", "background-clip" }, + "background" ); registerShorthand( "foreground", { "foreground-color", "foreground-image", "foreground-repeat", "foreground-position" }, @@ -1014,42 +1018,122 @@ void StyleSheetSpecification::registerDefaultShorthandParsers() { return {}; std::vector properties; const std::vector& propNames = shorthand->getProperties(); - std::vector tokens = String::split( value, " ", "", "(" ); - std::string positionStr; - for ( auto& tok : tokens ) { - auto open = tok.find_first_of( '(' ); + auto isRepeatKeyword = []( const std::string& s ) { + return -1 != String::valueIndex( s, "repeat;repeat-x;repeat-y;no-repeat;space;round" ); + }; - if ( open != std::string::npos && - mDrawableImageParser.exists( tok.substr( 0, open ) ) ) { - int pos = getIndexEndingWith( propNames, "-image" ); - if ( pos != -1 ) - properties.emplace_back( StyleSheetProperty( propNames[pos], tok ) ); - } else if ( -1 != String::valueIndex( tok, "repeat;repeat-x;repeat-y;no-repeat" ) ) { - int pos = getIndexEndingWith( propNames, "-repeat" ); - if ( pos != -1 ) - properties.emplace_back( StyleSheetProperty( propNames[pos], value ) ); - } else if ( -1 != String::valueIndex( tok, "left;right;top;bottom;center" ) || - String::isNumber( tok[0] ) || tok[0] == '-' || tok[0] == '.' || - tok[0] == '+' ) { - positionStr += tok + " "; + auto isBoxKeyword = []( const std::string& s ) { + return s == "border-box" || s == "padding-box" || s == "content-box"; + }; + + auto isAttachmentKeyword = []( const std::string& s ) { + return s == "scroll" || s == "fixed" || s == "local"; + }; + + auto isPositionKeyword = []( const std::string& s ) { + return s == "left" || s == "right" || s == "top" || s == "bottom" || s == "center"; + }; + + // Split by comma for multi-layer support + std::vector layers = String::split( value, ',' ); + + std::vector imageValues; + std::vector repeatValues; + std::vector attachmentValues; + std::vector originValues; + std::vector clipValues; + std::vector positionValues; + std::vector sizeValues; + std::string colorValue; + + for ( size_t layerIdx = 0; layerIdx < layers.size(); ++layerIdx ) { + std::string layerVal = String::trim( layers[layerIdx] ); + + std::vector tokens = String::split( layerVal, " ", "", "(" ); + std::string positionStr; + std::string sizeStr; + bool hasSlash{ false }; + std::string firstBox; + std::string secondBox; + + for ( size_t ti = 0; ti < tokens.size(); ++ti ) { + auto& tok = tokens[ti]; + auto open = tok.find_first_of( '(' ); + + if ( open != std::string::npos && + mDrawableImageParser.exists( tok.substr( 0, open ) ) ) { + imageValues.push_back( tok ); + } else if ( isRepeatKeyword( tok ) ) { + repeatValues.push_back( tok ); + } else if ( isAttachmentKeyword( tok ) ) { + attachmentValues.push_back( tok ); + } else if ( isBoxKeyword( tok ) ) { + if ( firstBox.empty() ) + firstBox = tok; + else + secondBox = tok; + } else if ( tok == "/" ) { + hasSlash = true; + } else if ( hasSlash && !tok.empty() && tok != "/" ) { + sizeStr += tok + " "; + } else if ( isPositionKeyword( tok ) || String::isNumber( tok[0] ) || + tok[0] == '-' || tok[0] == '.' || tok[0] == '+' ) { + positionStr += tok + " "; + } else { + if ( colorValue.empty() ) + colorValue = tok; + } + } + + originValues.push_back( firstBox.empty() ? "padding-box" : firstBox ); + clipValues.push_back( secondBox.empty() ? "border-box" : secondBox ); + + if ( !positionStr.empty() ) { + String::trimInPlace( positionStr ); + positionValues.push_back( positionStr ); } else { - int pos = getIndexEndingWith( propNames, "-color" ); - if ( pos != -1 ) - properties.emplace_back( StyleSheetProperty( propNames[pos], value ) ); + positionValues.push_back( "0% 0%" ); + } + + if ( !sizeStr.empty() ) { + String::trimInPlace( sizeStr ); + sizeValues.push_back( sizeStr ); + } else { + sizeValues.push_back( "auto" ); } } - if ( !positionStr.empty() ) { - String::trimInPlace( positionStr ); - int pos = getIndexEndingWith( propNames, "-position" ); - if ( pos != -1 ) { - const ShorthandDefinition* shorthand = getShorthand( propNames[pos] ); - if ( NULL != shorthand ) { - auto bpVec = mShorthandParsers["background-position"]( shorthand, positionStr ); + for ( auto& propName : propNames ) { + if ( String::endsWith( propName, "-color" ) && !colorValue.empty() ) { + properties.emplace_back( StyleSheetProperty( propName, colorValue ) ); + } else if ( String::endsWith( propName, "-image" ) && !imageValues.empty() ) { + properties.emplace_back( + StyleSheetProperty( propName, String::join( imageValues, ',' ) ) ); + } else if ( String::endsWith( propName, "-repeat" ) && !repeatValues.empty() ) { + properties.emplace_back( + StyleSheetProperty( propName, String::join( repeatValues, ',' ) ) ); + } else if ( String::endsWith( propName, "-attachment" ) && !attachmentValues.empty() ) { + properties.emplace_back( + StyleSheetProperty( propName, String::join( attachmentValues, ',' ) ) ); + } else if ( String::endsWith( propName, "-origin" ) ) { + properties.emplace_back( + StyleSheetProperty( propName, String::join( originValues, ',' ) ) ); + } else if ( String::endsWith( propName, "-clip" ) ) { + properties.emplace_back( + StyleSheetProperty( propName, String::join( clipValues, ',' ) ) ); + } else if ( String::endsWith( propName, "-position" ) ) { + // Let background-position sub-parser handle this + const ShorthandDefinition* posShorthand = getShorthand( propName ); + if ( NULL != posShorthand ) { + auto bpVec = mShorthandParsers["background-position"]( + posShorthand, String::join( positionValues, ',' ) ); for ( auto& bp : bpVec ) properties.emplace_back( bp ); } + } else if ( String::endsWith( propName, "-size" ) ) { + properties.emplace_back( + StyleSheetProperty( propName, String::join( sizeValues, ',' ) ) ); } } diff --git a/src/eepp/ui/tools/htmlformatter.cpp b/src/eepp/ui/tools/htmlformatter.cpp index a8985f131..8d9bfc632 100644 --- a/src/eepp/ui/tools/htmlformatter.cpp +++ b/src/eepp/ui/tools/htmlformatter.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include @@ -8,8 +8,6 @@ #include -using namespace EE::System; - namespace EE { namespace UI { namespace Tools { // Helper to escape text so pugixml doesn't crash on <, >, or & @@ -146,203 +144,6 @@ static void serializeGumboNodeToXML( GumboNode* node, std::string& out ) { } } -// In HTML, whitespace processing depends heavily on whether elements are block-level -// or inline-level. The HTML specification states that sequences of whitespace -// (spaces, tabs, newlines) inside inline formatting contexts are collapsed into a -// single space, but leading and trailing spaces are removed entirely if they adjoin -// a block boundary (e.g. at the start or end of a `

` or `

`). -// -// For example: -//

-// -// -// -//

-// In this snippet, the spaces and newlines between `

` and `` are completely -// dropped because they touch the block boundary of `

`. The spaces between `` and -// `` are inside an inline context, but because `` and `` are inline, they -// might normally produce a space, except leading/trailing rules can apply depending on -// significant text content. To properly emulate HTML's visual rendering, we must -// identify whether a node acts as an "inline" element. - -bool HTMLFormatter::isInlineNode( const pugi::xml_node& node ) { - if ( !node ) - return false; - if ( node.type() == pugi::node_pcdata ) - return true; - if ( node.type() != pugi::node_element ) - return false; - - // Compare element tags against known HTML inline elements and our internal equivalents. - std::string_view name( node.name() ); - return String::iequals( name, "a" ) || String::iequals( name, "span" ) || - String::iequals( name, "textspan" ) || String::iequals( name, "b" ) || - String::iequals( name, "i" ) || String::iequals( name, "strong" ) || - String::iequals( name, "em" ) || String::iequals( name, "s" ) || - String::iequals( name, "u" ) || String::iequals( name, "br" ) || - String::iequals( name, "code" ) || String::iequals( name, "img" ) || - String::iequals( name, "mark" ) || String::iequals( name, "font" ); -} - -// "Significant text" in the context of HTML whitespace collapsing means any text -// that is not entirely composed of whitespace characters, or elements that have a -// visual inline presence like images () or line breaks (
). -// Empty inline elements (e.g. ``) or those containing only whitespace -// are often ignored when evaluating boundaries for whitespace trimming. -// -// This function allows us to peer inside nodes or text blocks to see if they actually -// contain anything that visually anchors a whitespace sequence. If a node lacks -// significant text, the whitespace logic can skip over it to find the true logical -// boundary. - -bool HTMLFormatter::hasSignificantText( const pugi::xml_node& node ) { - if ( !node ) - return false; - - // For plain text, check if there's any non-whitespace character. - if ( node.type() == pugi::node_pcdata ) { - std::string_view v( node.value() ); - for ( char c : v ) { - if ( c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\v' ) - return true; - } - return false; - } - - // For inline elements, certain tags are inherently significant (img, br). - // Otherwise, we recursively check their children. - if ( isInlineNode( node ) ) { - std::string_view name( node.name() ); - if ( String::iequals( name, "img" ) || String::iequals( name, "br" ) ) - return true; - for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) { - if ( hasSignificantText( child ) ) - return true; - } - return false; - } - - // Block nodes inherently form a significant boundary. We don't look inside them - // because a block node interrupts the inline formatting context entirely. - return true; -} - -// In HTML, elements can be nested arbitrarily, meaning the "previous" inline node -// visually preceding a text block might not be its direct sibling in the DOM tree. -// For instance, in `text `, the space is technically a sibling -// of ``, but logically it follows `text`. -// -// `getLogicalPrev` traverses the DOM tree backward, diving into the rightmost children -// of previous siblings, or walking up to the parent, as long as the traversed nodes -// remain within the inline formatting context. This effectively finds the closest -// visual element to the left of the current node. - -pugi::xml_node HTMLFormatter::getLogicalPrev( const pugi::xml_node& node ) { - pugi::xml_node p = node; - while ( p ) { - // If there is a previous sibling, we move to it and then drill down - // to its last (rightmost) inline child, simulating visual left-to-right flow. - if ( p.previous_sibling() ) { - p = p.previous_sibling(); - while ( p.last_child() && isInlineNode( p ) ) - p = p.last_child(); - return p; - } - // If there are no previous siblings, we move up to the parent. - // If the parent is a block element, we've hit a block boundary and stop. - p = p.parent(); - if ( !isInlineNode( p ) ) - break; - } - return pugi::xml_node(); -} - -// `getLogicalNext` is the counterpart to `getLogicalPrev`. It traverses the DOM tree -// forward to find the closest visual element to the right of the current node. -// It drills into the leftmost children of next siblings, or walks up the parent tree -// to continue the forward search, bounded by block elements. - -pugi::xml_node HTMLFormatter::getLogicalNext( const pugi::xml_node& node ) { - pugi::xml_node p = node; - while ( p ) { - // Move to the next sibling and drill down to its first (leftmost) inline child. - if ( p.next_sibling() ) { - p = p.next_sibling(); - while ( p.first_child() && isInlineNode( p ) ) - p = p.first_child(); - return p; - } - // Move up to the parent, stopping at block boundaries. - p = p.parent(); - if ( !isInlineNode( p ) ) - break; - } - return pugi::xml_node(); -} - -// This function implements HTML-compliant whitespace collapsing for a given text node. -// -// HTML rules dictate that: -// 1. Any contiguous sequence of whitespace characters (spaces, tabs, newlines) -// is collapsed into a single space character (' '). -// 2. If this text node logically adjoins a block element (e.g. it is the first or last -// thing inside a `

`), the leading or trailing space is completely removed. -// 3. To accurately determine boundaries, we must skip over "empty" text nodes or -// elements that lack significant visual content. -// -// This function first collapses all whitespace into single spaces. Then it looks both -// logically backward and forward using `getLogicalPrev` and `getLogicalNext`. If it -// determines that there is no valid inline node on a given side (meaning it has hit -// a block boundary), it strips the space on that side. - -String HTMLFormatter::collapseXmlWhitespace( const String& text, const pugi::xml_node& node ) { - String res; - res.reserve( text.size() ); - bool inSpace = false; - - // Step 1: Collapse all contiguous whitespace characters into a single space. - for ( size_t i = 0; i < text.size(); ++i ) { - if ( text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r' || - text[i] == '\v' ) { - if ( !inSpace ) { - res += ' '; - inSpace = true; - } - } else { - res += text[i]; - inSpace = false; - } - } - - // Step 2: Determine if the left boundary is a block element or a forced line break (
). - // We use getLogicalPrev, and if the previous node is just empty space - // (lacks significant text), we keep looking further back. - pugi::xml_node prev = getLogicalPrev( node ); - while ( prev && prev.type() == pugi::node_pcdata && !hasSignificantText( prev ) ) { - prev = getLogicalPrev( prev ); - } - // A node is a valid inline neighbor only if it is an inline node AND not a
- // (because
strips adjacent whitespace). - bool prevInline = isInlineNode( prev ) && !String::iequals( prev.name(), "br" ); - - // Step 3: Determine if the right boundary is a block element or a forced line break. - // We use getLogicalNext, skipping over any non-significant text nodes. - pugi::xml_node next = getLogicalNext( node ); - while ( next && next.type() == pugi::node_pcdata && !hasSignificantText( next ) ) { - next = getLogicalNext( next ); - } - bool nextInline = isInlineNode( next ) && !String::iequals( next.name(), "br" ); - - // Step 4: Trim leading and trailing spaces if they adjoin a block boundary. - if ( !prevInline && !res.empty() && res[0] == ' ' ) - res = res.substr( 1 ); - - if ( !nextInline && !res.empty() && res.back() == ' ' ) - res = res.substr( 0, res.size() - 1 ); - - return res; -} - std::string HTMLFormatter::HTMLtoXML( const std::string& layoutString ) { if ( layoutString.empty() ) return ""; diff --git a/src/eepp/ui/uihtmlwidget.cpp b/src/eepp/ui/uihtmlwidget.cpp index fed61c101..c6eea656b 100644 --- a/src/eepp/ui/uihtmlwidget.cpp +++ b/src/eepp/ui/uihtmlwidget.cpp @@ -11,7 +11,9 @@ UIHTMLWidget* UIHTMLWidget::New() { return eeNew( UIHTMLWidget, () ); } -UIHTMLWidget::UIHTMLWidget( const std::string& tag ) : UILayout( tag ) {} +UIHTMLWidget::UIHTMLWidget( const std::string& tag ) : UILayout( tag ) { + mFlags |= UI_HTML_ELEMENT; +} UIHTMLWidget::~UIHTMLWidget() { if ( mScrollTarget && mScrollCb ) diff --git a/src/eepp/ui/uinode.cpp b/src/eepp/ui/uinode.cpp index 0d0059ff6..4b18facd5 100644 --- a/src/eepp/ui/uinode.cpp +++ b/src/eepp/ui/uinode.cpp @@ -772,6 +772,21 @@ UINode* UINode::setBackgroundSize( const std::string& size, int index ) { return this; } +UINode* UINode::setBackgroundOrigin( const std::string& origin, int index ) { + setBackgroundFillEnabled( true )->setDrawableOrigin( index, origin ); + return this; +} + +UINode* UINode::setBackgroundClip( const std::string& clip, int index ) { + setBackgroundFillEnabled( true )->setDrawableClip( index, clip ); + return this; +} + +UINode* UINode::setBackgroundAttachment( const std::string& attachment, int index ) { + setBackgroundFillEnabled( true )->setDrawableAttachment( index, attachment ); + return this; +} + Color UINode::getBackgroundColor() const { return NULL != mBackground ? mBackground->getBackgroundColor() : Color::Transparent; } @@ -1186,6 +1201,8 @@ const Rectf& UINode::getPixelsPadding() const { UINodeDrawable* UINode::getBackground() const { if ( NULL == mBackground ) { mBackground = UINodeDrawable::New( const_cast( this ) ); + if ( mFlags & UI_HTML_ELEMENT ) + mBackground->setBackgroundMode( BackgroundMode::Html ); } return mBackground; diff --git a/src/eepp/ui/uinodedrawable.cpp b/src/eepp/ui/uinodedrawable.cpp index 14d11ed1c..050e71046 100644 --- a/src/eepp/ui/uinodedrawable.cpp +++ b/src/eepp/ui/uinodedrawable.cpp @@ -7,19 +7,73 @@ #include #include #include +#include #include using namespace EE::Math::easing; namespace EE { namespace UI { -UINodeDrawable::Repeat UINodeDrawable::repeatFromText( const std::string& text ) { - if ( "repeat" == text ) - return UINodeDrawable::Repeat::RepeatXY; - if ( "repeat-x" == text ) - return UINodeDrawable::Repeat::RepeatX; - if ( "repeat-y" == text ) - return UINodeDrawable::Repeat::RepeatY; - return UINodeDrawable::Repeat::NoRepeat; +void UINodeDrawable::repeatFromText( const std::string& text, RepeatX& repeatX, RepeatY& repeatY ) { + auto parts = String::split( text, ' ' ); + + std::string xVal; + std::string yVal; + + if ( parts.size() == 1 ) { + if ( "repeat-x" == parts[0] ) { + xVal = "repeat"; + yVal = "no-repeat"; + } else if ( "repeat-y" == parts[0] ) { + xVal = "no-repeat"; + yVal = "repeat"; + } else { + xVal = parts[0]; + yVal = parts[0]; + } + } else if ( parts.size() >= 2 ) { + xVal = parts[0]; + yVal = parts[1]; + } + + auto parseOne = []( const std::string& val ) { + if ( "no-repeat" == val ) + return RepeatX::NoRepeat; + if ( "space" == val ) + return RepeatX::Space; + if ( "round" == val ) + return RepeatX::Round; + return RepeatX::Repeat; + }; + + repeatX = parseOne( xVal ); + repeatY = static_cast( parseOne( yVal ) ); +} + +UINodeDrawable::LayerDrawable::Origin +UINodeDrawable::LayerDrawable::originFromText( const std::string& text ) { + if ( "border-box" == text ) + return Origin::BorderBox; + if ( "content-box" == text ) + return Origin::ContentBox; + return Origin::PaddingBox; +} + +UINodeDrawable::LayerDrawable::Clip +UINodeDrawable::LayerDrawable::clipFromText( const std::string& text ) { + if ( "padding-box" == text ) + return Clip::PaddingBox; + if ( "content-box" == text ) + return Clip::ContentBox; + return Clip::BorderBox; +} + +UINodeDrawable::LayerDrawable::Attachment +UINodeDrawable::LayerDrawable::attachmentFromText( const std::string& text ) { + if ( "fixed" == text ) + return Attachment::Fixed; + if ( "local" == text ) + return Attachment::Local; + return Attachment::Scroll; } UINodeDrawable* UINodeDrawable::New( UINode* owner ) { @@ -64,9 +118,19 @@ bool UINodeDrawable::layerExists( int index ) { UINodeDrawable::LayerDrawable* UINodeDrawable::getLayer( int index ) { auto it = mGroup.find( index ); - if ( it == mGroup.end() ) + if ( it == mGroup.end() ) { mGroup[index] = UINodeDrawable::LayerDrawable::New( this ); + // HTML background-repeat defaults to "repeat", non-HTML to + // "no-repeat". The LayerDrawable constructor uses NoRepeat + // (the eepp/non-HTML default), so reset it for Html mode. + if ( mBackgroundMode == BackgroundMode::Html ) { + auto* layer = mGroup[index]; + layer->setRepeatX( RepeatX::Repeat ); + layer->setRepeatY( RepeatY::Repeat ); + } + } + return mGroup[index]; } @@ -91,10 +155,15 @@ void UINodeDrawable::setDrawablePositionY( int index, const std::string& positio } void UINodeDrawable::setDrawableRepeat( int index, const std::string& repeatRule ) { - getLayer( index )->setRepeat( repeatFromText( repeatRule ) ); + RepeatX rx; + RepeatY ry; + repeatFromText( repeatRule, rx, ry ); + getLayer( index )->setRepeatX( rx ); + getLayer( index )->setRepeatY( ry ); for ( auto& layIt : mGroup ) { - if ( layIt.second->getRepeat() != Repeat::NoRepeat ) { + if ( layIt.second->getRepeatX() != RepeatX::NoRepeat || + layIt.second->getRepeatY() != RepeatY::NoRepeat ) { setClipEnabled( true ); break; } @@ -105,6 +174,18 @@ void UINodeDrawable::setDrawableSize( int index, const std::string& sizeEq ) { getLayer( index )->setSizeEq( sizeEq ); } +void UINodeDrawable::setDrawableOrigin( int index, const std::string& origin ) { + getLayer( index )->setOrigin( origin ); +} + +void UINodeDrawable::setDrawableClip( int index, const std::string& clip ) { + getLayer( index )->setClip( clip ); +} + +void UINodeDrawable::setDrawableAttachment( int index, const std::string& attachment ) { + getLayer( index )->setAttachment( attachment ); +} + void UINodeDrawable::setDrawableColor( int index, const Color& color ) { getLayer( index )->setColor( color ); } @@ -147,6 +228,28 @@ void UINodeDrawable::setSmooth( bool smooth ) { getBackgroundDrawable().setSmooth( smooth ); } +void UINodeDrawable::setBackgroundMode( BackgroundMode mode ) { + mBackgroundMode = mode; + + // HTML background-repeat defaults to "repeat", non-HTML defaults to + // "no-repeat". When switching mode, update any existing layers that + // still carry the LayerDrawable default (NoRepeat for both axes). + if ( mode == BackgroundMode::Html ) { + for ( auto& entry : mGroup ) { + auto* layer = entry.second; + if ( layer->getRepeatX() == RepeatX::NoRepeat && + layer->getRepeatY() == RepeatY::NoRepeat ) { + layer->setRepeatX( RepeatX::Repeat ); + layer->setRepeatY( RepeatY::Repeat ); + } + } + } +} + +BackgroundMode UINodeDrawable::getBackgroundMode() const { + return mBackgroundMode; +} + Sizef UINodeDrawable::getSize() { return mSize; } @@ -180,7 +283,7 @@ void UINodeDrawable::draw( const Vector2f& position, const Sizef& size, const Ui if ( mNeedsUpdate ) update(); - if ( mClipEnabled ) + if ( mClipEnabled || mBackgroundMode == BackgroundMode::Html ) GLi->getClippingMask()->clipPlaneEnable( mPosition.x, mPosition.y, mSize.x, mSize.y ); if ( mBackgroundColor.getColor().a != 0 ) { @@ -221,6 +324,20 @@ void UINodeDrawable::draw( const Vector2f& position, const Sizef& size, const Ui for ( auto drawableIt = mGroup.rbegin(); drawableIt != mGroup.rend(); ++drawableIt ) { UINodeDrawable::LayerDrawable* drawable = drawableIt->second; + bool clipContent = mBackgroundMode == BackgroundMode::Html && + drawable->getClip() == LayerDrawable::Clip::ContentBox; + Rectf contentRect; + if ( clipContent && mOwner->isWidget() ) { + Rectf pad = mOwner->asType()->getPixelsPadding(); + contentRect.Left = mPosition.x + pad.Left; + contentRect.Top = mPosition.y + pad.Top; + contentRect.Right = mPosition.x + mSize.x - pad.Right; + contentRect.Bottom = mPosition.y + mSize.y - pad.Bottom; + GLi->getClippingMask()->clipPlaneEnable( contentRect.Left, contentRect.Top, + contentRect.Right - contentRect.Left, + contentRect.Bottom - contentRect.Top ); + } + if ( alpha != 255 ) { Color color = drawable->getColor(); drawable->setAlpha( alpha * color.a / 255 ); @@ -229,6 +346,9 @@ void UINodeDrawable::draw( const Vector2f& position, const Sizef& size, const Ui } else { drawable->draw( position, size ); } + + if ( clipContent ) + GLi->getClippingMask()->clipPlaneDisable(); } if ( mSmooth ) { @@ -243,7 +363,7 @@ void UINodeDrawable::draw( const Vector2f& position, const Sizef& size, const Ui clippingMask->stencilMaskDisable(); } - if ( mClipEnabled ) + if ( mClipEnabled || mBackgroundMode == BackgroundMode::Html ) GLi->getClippingMask()->clipPlaneDisable(); } @@ -285,7 +405,14 @@ UINodeDrawable::LayerDrawable::LayerDrawable( UINodeDrawable* container ) : mOwnsDrawable( false ), mDrawable( NULL ), mResourceChangeCbId( 0 ), - mRepeat( Repeat::NoRepeat ) {} + mRepeatX( RepeatX::NoRepeat ), + mRepeatY( RepeatY::NoRepeat ), + mOriginEq( "padding-box" ), + mClipEq( "border-box" ), + mAttachmentEq( "scroll" ), + mOrigin( Origin::PaddingBox ), + mClip( Clip::BorderBox ), + mAttachment( Attachment::Scroll ) {} UINodeDrawable::LayerDrawable::~LayerDrawable() { if ( NULL != mDrawable && 0 != mResourceChangeCbId && mDrawable->isDrawableResource() ) { @@ -344,48 +471,112 @@ void UINodeDrawable::LayerDrawable::draw( const Vector2f& position, const Sizef& mDrawable->setColorFilter( getColor() ); mDrawable->setAlpha( getAlpha() ); - switch ( mRepeat ) { - case Repeat::NoRepeat: - mDrawable->draw( mPosition + mOffset, mDrawableSize ); + Vector2f effectivePos = mPosition; + if ( mAttachment == Attachment::Fixed ) { + auto* sceneNode = mContainer->getOwner()->getUISceneNode(); + if ( sceneNode ) + effectivePos = sceneNode->getPosition(); + } + + auto drawY = [this, &effectivePos]( Float xPos, Float drawH, Sizef tileSz ) { + switch ( mRepeatY ) { + case RepeatY::NoRepeat: + mDrawable->draw( Vector2f( xPos, effectivePos.y + mOffset.y ), tileSz ); + break; + case RepeatY::Repeat: + repeatYdraw( mDrawable, effectivePos, Vector2f( xPos - effectivePos.x, mOffset.y ), + mSize, tileSz ); + break; + case RepeatY::Space: { + if ( drawH <= 0 ) + break; + int count = (int)( mSize.getHeight() / drawH ); + if ( count < 1 ) + count = 1; + Float totalTilesH = count * drawH; + Float gap = ( mSize.getHeight() - totalTilesH ) / (Float)( count + 1 ); + for ( int i = 0; i < count; ++i ) { + Float y = effectivePos.y + gap + (Float)i * ( drawH + gap ); + mDrawable->draw( Vector2f( xPos, y ), tileSz ); + } + break; + } + case RepeatY::Round: { + if ( drawH <= 0 ) + break; + Float scale = mSize.getHeight() / drawH; + int count = (int)Math::round( scale ); + if ( count < 1 ) + count = 1; + Float roundedH = mSize.getHeight() / (Float)count; + Float aspect = drawH > 0 ? tileSz.getWidth() / tileSz.getHeight() : 1.0f; + Sizef roundSz( roundedH * aspect, roundedH ); + for ( int i = 0; i < count; ++i ) { + Float y = effectivePos.y + (Float)i * roundedH; + mDrawable->draw( Vector2f( xPos, y ), roundSz ); + } + break; + } + default: + break; + } + }; + + switch ( mRepeatX ) { + case RepeatX::NoRepeat: + drawY( effectivePos.x + mOffset.x, mDrawableSize.getHeight(), mDrawableSize ); break; - case Repeat::RepeatX: { + case RepeatX::Repeat: { if ( mDrawableSize.getWidth() > 0 ) { - Float startX = mPosition.x + mOffset.x - mDrawableSize.getWidth(); - while ( startX > mPosition.x - mDrawableSize.getWidth() ) { - mDrawable->draw( Vector2f( startX, mPosition.y + mOffset.y ), mDrawableSize ); + Float startX = effectivePos.x + mOffset.x - mDrawableSize.getWidth(); + while ( startX > effectivePos.x - mDrawableSize.getWidth() ) { + drawY( startX, mDrawableSize.getHeight(), mDrawableSize ); startX -= mDrawableSize.getWidth(); - }; - mDrawable->draw( mPosition + mOffset, mDrawableSize ); - startX = mPosition.x + mOffset.x + mDrawableSize.getWidth(); - while ( startX < mPosition.x + mSize.getWidth() ) { - mDrawable->draw( Vector2f( startX, mPosition.y + mOffset.y ), mDrawableSize ); + } + drawY( effectivePos.x + mOffset.x, mDrawableSize.getHeight(), mDrawableSize ); + startX = effectivePos.x + mOffset.x + mDrawableSize.getWidth(); + while ( startX < effectivePos.x + mSize.getWidth() ) { + drawY( startX, mDrawableSize.getHeight(), mDrawableSize ); startX += mDrawableSize.getWidth(); - }; + } } break; } - case Repeat::RepeatY: { - repeatYdraw( mDrawable, mPosition, mOffset, mSize, mDrawableSize ); - break; - } - case Repeat::RepeatXY: { - if ( mDrawableSize.getWidth() > 0 ) { - Float startX = mPosition.x + mOffset.x - mDrawableSize.getWidth(); - while ( startX > mPosition.x - mDrawableSize.getWidth() ) { - repeatYdraw( mDrawable, mPosition, Vector2f( startX - mPosition.x, mOffset.y ), - mSize, mDrawableSize ); - startX -= mDrawableSize.getWidth(); - }; - repeatYdraw( mDrawable, mPosition, mOffset, mSize, mDrawableSize ); - startX = mPosition.x + mOffset.x + mDrawableSize.getWidth(); - while ( startX < mPosition.x + mSize.getWidth() ) { - repeatYdraw( mDrawable, mPosition, Vector2f( startX - mPosition.x, mOffset.y ), - mSize, mDrawableSize ); - startX += mDrawableSize.getWidth(); - }; + case RepeatX::Space: { + if ( mDrawableSize.getWidth() <= 0 ) + break; + int count = (int)( mSize.getWidth() / mDrawableSize.getWidth() ); + if ( count < 1 ) + count = 1; + Float totalTilesW = count * mDrawableSize.getWidth(); + Float gap = ( mSize.getWidth() - totalTilesW ) / (Float)( count + 1 ); + for ( int i = 0; i < count; ++i ) { + Float x = effectivePos.x + gap + (Float)i * ( mDrawableSize.getWidth() + gap ); + drawY( x, mDrawableSize.getHeight(), mDrawableSize ); } break; } + case RepeatX::Round: { + if ( mDrawableSize.getWidth() <= 0 ) + break; + Float scale = mSize.getWidth() / mDrawableSize.getWidth(); + int count = (int)Math::round( scale ); + if ( count < 1 ) + count = 1; + Float roundedW = mSize.getWidth() / (Float)count; + Float aspect = mDrawableSize.getWidth() > 0 + ? mDrawableSize.getHeight() / mDrawableSize.getWidth() + : 1.0f; + Float roundedH = roundedW * aspect; + Sizef roundSz( roundedW, roundedH ); + for ( int i = 0; i < count; ++i ) { + Float x = effectivePos.x + (Float)i * roundedW; + drawY( x, roundedH, roundSz ); + } + break; + } + default: + break; } if ( mColorWasSet ) mDrawable->setColorFilter( prevColor ); @@ -471,13 +662,24 @@ const Vector2f& UINodeDrawable::LayerDrawable::getOffset() const { return mOffset; } -const UINodeDrawable::Repeat& UINodeDrawable::LayerDrawable::getRepeat() const { - return mRepeat; +UINodeDrawable::RepeatX UINodeDrawable::LayerDrawable::getRepeatX() const { + return mRepeatX; } -void UINodeDrawable::LayerDrawable::setRepeat( const UINodeDrawable::Repeat& repeat ) { - if ( mRepeat != repeat ) { - mRepeat = repeat; +UINodeDrawable::RepeatY UINodeDrawable::LayerDrawable::getRepeatY() const { + return mRepeatY; +} + +void UINodeDrawable::LayerDrawable::setRepeatX( RepeatX repeatX ) { + if ( mRepeatX != repeatX ) { + mRepeatX = repeatX; + invalidate(); + } +} + +void UINodeDrawable::LayerDrawable::setRepeatY( RepeatY repeatY ) { + if ( mRepeatY != repeatY ) { + mRepeatY = repeatY; invalidate(); } } @@ -515,12 +717,8 @@ Sizef UINodeDrawable::LayerDrawable::calcDrawableSize( const std::string& drawab Sizef drawableSize( mDrawable->getSize() ); Float Scale1 = mSize.getWidth() / drawableSize.getWidth(); Float Scale2 = mSize.getHeight() / drawableSize.getHeight(); - if ( Scale1 < 1 || Scale2 < 1 ) { - Scale1 = eemin( Scale1, Scale2 ); - size = Sizef( drawableSize.getWidth() * Scale1, drawableSize.getHeight() * Scale1 ); - } else { - size = drawableSize; - } + Scale1 = eemin( Scale1, Scale2 ); + size = Sizef( drawableSize.getWidth() * Scale1, drawableSize.getHeight() * Scale1 ); } else if ( drawableSizeEq == "cover" ) { Sizef drawableSize( mDrawable->getSize() ); Float Scale1 = mSize.getWidth() / drawableSize.getWidth(); @@ -588,40 +786,58 @@ Vector2f UINodeDrawable::LayerDrawable::calcPosition( std::string positionXEq, bool needsRoundingX = positionXEq.back() == '%'; bool needsRoundingY = positionYEq.back() == '%'; + Sizef ownerSize = mContainer->getOwner()->getPixelsSize(); + Float refWidth = ownerSize.getWidth(); + Float refHeight = ownerSize.getHeight(); + Float originOffX = 0; + Float originOffY = 0; + + if ( mContainer->getBackgroundMode() == BackgroundMode::Html && + mOrigin == Origin::ContentBox ) { + Rectf pad = mContainer->getOwner()->getPixelsPadding(); + refWidth -= pad.Left + pad.Right; + refHeight -= pad.Top + pad.Bottom; + originOffX = pad.Left; + originOffY = pad.Top; + } + if ( posX.size() == 2 ) { CSS::StyleSheetLength xl1( posX[0] ); CSS::StyleSheetLength xl2( posX[1] ); - position.x = mContainer->getOwner()->convertLength( - xl1, mContainer->getOwner()->getPixelsSize().getWidth() - mDrawableSize.getWidth() ); + position.x = + mContainer->getOwner()->convertLength( xl1, refWidth - mDrawableSize.getWidth() ); - Float xl2Val = mContainer->getOwner()->convertLength( - xl2, mContainer->getOwner()->getPixelsSize().getWidth() - mDrawableSize.getWidth() ); + Float xl2Val = + mContainer->getOwner()->convertLength( xl2, refWidth - mDrawableSize.getWidth() ); position.x += ( posX[0] == "right" ) ? -xl2Val : xl2Val; } else { CSS::StyleSheetLength xl( posX[0] ); - position.x = mContainer->getOwner()->convertLength( - xl, mContainer->getOwner()->getPixelsSize().getWidth() - mDrawableSize.getWidth() ); + position.x = + mContainer->getOwner()->convertLength( xl, refWidth - mDrawableSize.getWidth() ); } if ( posY.size() == 2 ) { CSS::StyleSheetLength yl1( posY[0] ); CSS::StyleSheetLength yl2( posY[1] ); - position.y = mContainer->getOwner()->convertLength( - yl1, mContainer->getOwner()->getPixelsSize().getHeight() - mDrawableSize.getHeight() ); + position.y = + mContainer->getOwner()->convertLength( yl1, refHeight - mDrawableSize.getHeight() ); - Float xl2Val = mContainer->getOwner()->convertLength( - yl2, mContainer->getOwner()->getPixelsSize().getHeight() - mDrawableSize.getHeight() ); + Float xl2Val = + mContainer->getOwner()->convertLength( yl2, refHeight - mDrawableSize.getHeight() ); position.y += ( posY[0] == "bottom" ) ? -xl2Val : xl2Val; } else { CSS::StyleSheetLength yl( posY[0] ); - position.y = mContainer->getOwner()->convertLength( - yl, mContainer->getOwner()->getPixelsSize().getHeight() - mDrawableSize.getHeight() ); + position.y = + mContainer->getOwner()->convertLength( yl, refHeight - mDrawableSize.getHeight() ); } + position.x += originOffX; + position.y += originOffY; + if ( needsRoundingX ) position.x = Math::round( position.x ); @@ -642,6 +858,54 @@ void UINodeDrawable::LayerDrawable::setSizeEq( const std::string& sizeEq ) { } } +void UINodeDrawable::LayerDrawable::setOrigin( const std::string& origin ) { + if ( mOriginEq != origin ) { + mOriginEq = origin; + mOrigin = originFromText( origin ); + invalidate(); + } +} + +void UINodeDrawable::LayerDrawable::setClip( const std::string& clip ) { + if ( mClipEq != clip ) { + mClipEq = clip; + mClip = clipFromText( clip ); + invalidate(); + } +} + +void UINodeDrawable::LayerDrawable::setAttachment( const std::string& attachment ) { + if ( mAttachmentEq != attachment ) { + mAttachmentEq = attachment; + mAttachment = attachmentFromText( attachment ); + invalidate(); + } +} + +UINodeDrawable::LayerDrawable::Origin UINodeDrawable::LayerDrawable::getOrigin() const { + return mOrigin; +} + +UINodeDrawable::LayerDrawable::Clip UINodeDrawable::LayerDrawable::getClip() const { + return mClip; +} + +UINodeDrawable::LayerDrawable::Attachment UINodeDrawable::LayerDrawable::getAttachment() const { + return mAttachment; +} + +const std::string& UINodeDrawable::LayerDrawable::getOriginEq() const { + return mOriginEq; +} + +const std::string& UINodeDrawable::LayerDrawable::getClipEq() const { + return mClipEq; +} + +const std::string& UINodeDrawable::LayerDrawable::getAttachmentEq() const { + return mAttachmentEq; +} + const std::string& UINodeDrawable::LayerDrawable::getPositionY() const { return mPositionY; } diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 977936009..61550af58 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -596,12 +595,10 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { } } } else if ( child.type() == pugi::node_pcdata ) { - String text = Tools::HTMLFormatter::collapseXmlWhitespace( child.value(), child ); - if ( !text.empty() ) { - UITextNode* span = UITextNode::New(); - span->setParent( this ); - span->setText( text ); - } + String collapsed = UIRichText::collapseInternalWhitespace( child.value() ); + UITextNode* textNode = UITextNode::New(); + textNode->setParent( this ); + textNode->setText( collapsed ); } } @@ -640,6 +637,24 @@ void UIRichText::onFontStyleChanged() { notifyLayoutAttrChangeParent(); } +String UIRichText::UIRichText::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; +} + void UIRichText::rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode ) { richText.clear(); Float maxWidth = 0; @@ -682,20 +697,78 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri } auto processNode = [&]( Node* node, auto& processNodeRef ) -> void { - if ( node->isTextNode() ) { - UITextNode* textNode = static_cast( node ); - if ( !textNode->getText().empty() ) { - FontStyleConfig style; - if ( node->getParent()->isType( UI_TYPE_TEXTSPAN ) ) { - style = node->getParent()->asType()->getFontStyleConfig(); - } else if ( node->getParent()->isType( UI_TYPE_RICHTEXT ) ) { - style = - node->getParent()->asType()->getRichText().getFontStyleConfig(); - } else { - style = richText.getFontStyleConfig(); + // Helper: walk up through inline ancestors to find the logical prev/next widget + auto findLogicalPrev = []( Node* n ) -> Node* { + while ( n ) { + Node* sib = n->getPrevNode(); + while ( sib && sib->isTextNode() ) { + auto* tn = sib->asType(); + if ( !tn->isWhitespaceOnly() ) + return sib; + sib = sib->getPrevNode(); } - richText.addSpan( textNode->getText(), style ); + if ( sib && sib->isWidget() ) + return sib; + n = n->getParent(); + if ( !n || !n->isWidget() || !n->asType()->isInlineDisplay() ) + break; } + return nullptr; + }; + auto findLogicalNext = []( Node* n ) -> Node* { + while ( n ) { + Node* sib = n->getNextNode(); + while ( sib && sib->isTextNode() ) { + auto* tn = sib->asType(); + if ( !tn->isWhitespaceOnly() ) + return sib; + sib = sib->getNextNode(); + } + if ( sib && sib->isWidget() ) + return sib; + n = n->getParent(); + if ( !n || !n->isWidget() || !n->asType()->isInlineDisplay() ) + break; + } + return nullptr; + }; + + if ( node->isTextNode() ) { + UITextNode* textNode = node->asType(); + String text = textNode->getText(); + + Node* prev = findLogicalPrev( node ); + bool prevIsInline = + prev && prev->isWidget() && prev->asType()->isInlineDisplay(); + + Node* next = findLogicalNext( node ); + bool nextIsInline = + next && next->isWidget() && next->asType()->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() ) { + textNode->setLayoutCharCount( 0 ); + return; + } + + textNode->setLayoutCharCount( text.length() ); + + FontStyleConfig style; + if ( node->getParent()->isType( UI_TYPE_TEXTSPAN ) ) { + style = node->getParent()->asType()->getFontStyleConfig(); + } else if ( node->getParent()->isType( UI_TYPE_RICHTEXT ) ) { + style = node->getParent()->asType()->getRichText().getFontStyleConfig(); + } else { + style = richText.getFontStyleConfig(); + } + richText.addSpan( text, style ); return; } @@ -712,7 +785,23 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri bool hasOwnText = !span->getText().empty() && NULL != span->getFontStyleConfig().Font; if ( hasOwnText ) { - richText.addSpan( span->getText(), span->getFontStyleConfig(), margin, padding ); + String::View spanText = span->getText().view(); + + Node* prev = findLogicalPrev( node ); + bool prevIsInline = + prev && prev->isWidget() && prev->asType()->isInlineDisplay(); + + Node* next = findLogicalNext( node ); + bool nextIsInline = + next && next->isWidget() && next->asType()->isInlineDisplay(); + + if ( !prevIsInline && !spanText.empty() && spanText[0] == ' ' ) + spanText = spanText.substr( 1 ); + if ( !nextIsInline && !spanText.empty() && spanText.back() == ' ' ) + spanText = spanText.substr( 0, spanText.size() - 1 ); + + if ( !spanText.empty() ) + richText.addSpan( spanText, span->getFontStyleConfig(), margin, padding ); } 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 ); diff --git a/src/eepp/ui/uitextnode.cpp b/src/eepp/ui/uitextnode.cpp index 296a99887..eecbe32df 100644 --- a/src/eepp/ui/uitextnode.cpp +++ b/src/eepp/ui/uitextnode.cpp @@ -53,4 +53,12 @@ void UITextNode::setText( const String& text ) { } } +bool UITextNode::isWhitespaceOnly() const { + for ( char c : mText ) { + if ( c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\v' ) + return false; + } + return true; +} + }} // namespace EE::UI diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp index 84bcb06c9..9ae1927d8 100644 --- a/src/eepp/ui/uitextspan.cpp +++ b/src/eepp/ui/uitextspan.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -409,18 +408,16 @@ void UITextSpan::loadFromXmlNode( const pugi::xml_node& node ) { widget->loadFromXmlNode( child ); } } else if ( child.type() == pugi::node_pcdata ) { - String text = Tools::HTMLFormatter::collapseXmlWhitespace( child.value(), child ); - if ( !text.empty() ) { - UITextNode* span = UITextNode::New(); - span->setParent( this ); - span->setText( text ); - } + String collapsed = UIRichText::collapseInternalWhitespace( child.value() ); + UITextNode* textNode = UITextNode::New(); + textNode->setParent( this ); + textNode->setText( collapsed ); } } } else { for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) { if ( child.type() == pugi::node_pcdata ) { - mText += Tools::HTMLFormatter::collapseXmlWhitespace( child.value(), child ); + mText += UIRichText::collapseInternalWhitespace( child.value() ); } } } diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index 3b2fa8bf2..d6fca39c0 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -1001,6 +1002,16 @@ bool UIWidget::isWidgetElement() const { return !isTextNode(); } +bool UIWidget::isInlineDisplay() const { + if ( isTextNode() ) + return true; + if ( isType( UI_TYPE_HTML_WIDGET ) ) { + CSSDisplay d = static_cast( this )->getDisplay(); + return d == CSSDisplay::Inline || d == CSSDisplay::InlineBlock; + } + return false; +} + Uint32 UIWidget::getElementIndex() const { Uint32 index = 0; if ( NULL != mParentNode ) { @@ -1760,6 +1771,12 @@ std::string UIWidget::getPropertyString( const PropertyDefinition* propertyDef, return getBackground()->getLayer( propertyIndex )->getPositionX(); case PropertyId::BackgroundPositionY: return getBackground()->getLayer( propertyIndex )->getPositionY(); + case PropertyId::BackgroundOrigin: + return getBackground()->getLayer( propertyIndex )->getOriginEq(); + case PropertyId::BackgroundClip: + return getBackground()->getLayer( propertyIndex )->getClipEq(); + case PropertyId::BackgroundAttachment: + return getBackground()->getLayer( propertyIndex )->getAttachmentEq(); case PropertyId::ForegroundPositionX: return getForeground()->getLayer( propertyIndex )->getPositionX(); case PropertyId::ForegroundPositionY: @@ -1972,6 +1989,15 @@ bool UIWidget::applyProperty( const StyleSheetProperty& attribute ) { case PropertyId::BackgroundSize: setBackgroundSize( attribute.value(), attribute.getIndex() ); break; + 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; case PropertyId::ForegroundColor: setForegroundColor( attribute.asColor() ); break; diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index 936f06988..03874f85e 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -1316,3 +1317,77 @@ UTEST( UIHTML, ContactFormLayout ) { Engine::destroySingleton(); } + +UTEST( UIBackground, imageAtlasPositioning ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 960, 256, "Background Atlas Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + UI::UISceneNode* sceneNode = init_test_inline_block(); + + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + std::string html; + FileSystem::fileGet( "assets/html/background_atlas.html", html ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + win->setClearColor( Color::White ); + + // Verify the atlas image was actually loaded — scan all nodes for a loaded drawable + bool foundLoadedImage = false; + sceneNode->getRoot()->forEachNode( [&foundLoadedImage]( Node* node ) { + if ( foundLoadedImage || !node->isWidget() ) + return; + auto* bg = node->asType()->getBackground(); + if ( !bg ) + return; + auto* layer = bg->getLayer( 0 ); + if ( layer && layer->getDrawable() ) { + Sizef sz = layer->getDrawable()->getPixelsSize(); + if ( sz.getWidth() == 1024.f && sz.getHeight() == 512.f ) + foundLoadedImage = true; + } + } ); + ASSERT_TRUE( foundLoadedImage ); + + win->getInput()->update(); + SceneManager::instance()->update(); + + win->clear(); + SceneManager::instance()->draw(); + win->display(); + + compareImages( utest_state, utest_result, win, "eepp-ui-background-atlas", "html", 4 ); + + Engine::destroySingleton(); +} + +UTEST( UIBackground, imageAtlasPositioningPixelDensity2 ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 960, 256, "Background Atlas Test PD2", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + EE::Graphics::PixelDensity::setPixelDensity( 2.0f ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + UI::UISceneNode* sceneNode = init_test_inline_block(); + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + std::string html; + FileSystem::fileGet( "assets/html/background_atlas.html", html ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + win->setClearColor( Color::White ); + + win->getInput()->update(); + SceneManager::instance()->update(); + + win->clear(); + SceneManager::instance()->draw(); + win->display(); + + compareImages( utest_state, utest_result, win, "eepp-ui-background-atlas-pd2", "html", 4 ); + + Engine::destroySingleton(); + EE::Graphics::PixelDensity::setPixelDensity( 1.0f ); +}