ui: Full HTML background property support + whitespace collapsing rewrite

Part 1: CSS background properties (UINodeDrawable, LayerDrawable)
Add `BackgroundMode` (Native / Html) to `UINodeDrawable`. When Html mode
is active, background rendering follows CSS/HTML semantics (repeat defaults
to repeat, clip plane always enabled, etc.). Native mode preserves the
original eepp behavior (no-repeat by default, clip only when repeating).
Add per-layer `background-origin`, `background-clip`, `background-attachment`
with `fromText` parsers and CSS property registration. `background-origin`
controls the reference box for position/percentage resolution.
`background-clip` enables per-layer content-box clipping.
`background-attachment: fixed` anchors the background to the scene root;
`local` is stubbed (needs per-widget scroll offset plumbing).
Replace the single `Repeat` enum with `RepeatX` / `RepeatY` enums supporting
two-value repeat (e.g. "repeat no-repeat", "space round"). Implement space
and round repeat rendering in `LayerDrawable::draw()`.
Fix `background-size: contain` to scale up (not just down) like HTML.
Rewrite the comma-separated multi-layer `background` shorthand parser with
`/size` separator, box keyword disambiguation, and attachment keywords.
UINode::{getBackground} detects `UI_HTML_ELEMENT` flag and lazily sets
`BackgroundMode::Html`.
Add golden image test: 20-tile image atlas via
`background: url(bnp.png) pos / size no-repeat` with browser-comparable HTML.
Part 2: HTML whitespace collapsing (HTMLFormatter → UIRichText)
Move whitespace collapsing from parse time to layout time. Previously
`HTMLFormatter::collapseXmlWhitespace` ran on the pugixml DOM before
CSS was resolved, using tag-name heuristics for inline/block detection.
This caused whitespace between elements with `display: inline-block`
(via CSS) to be incorrectly stripped.
The new pipeline preserves raw whitespace in `UITextNode` widgets (after
internal whitespace collapsing via `UIRichText::collapseInternalWhitespace`)
and defers boundary stripping to `UIRichText::rebuildRichText`, where every
widget's computed `CSSDisplay` is available via `UIWidget::isInlineDisplay()`.
- `UIWidget::isInlineDisplay()` returns true for text nodes and widgets
  with `CSSDisplay::Inline | InlineBlock`, used by boundary logic.
- `UITextNode::isWhitespaceOnly()` identifies collapsible text nodes.
- `findLogicalPrev/Next` walks up through inline ancestors to find the
  correct boundary element, matching HTML's whitespace transparency rule.
- Boundary stripping: leading/trailing spaces removed at block edges,
  preserved between inline-level siblings.
- Double-space prevention: when adding text that starts with a space,
  peeks at the last `SpanBlock` in RichText if it ends with a space,
  the leading space is stripped. This prevents consecutive spaces when
  whitespace nodes are separated by empty inline elements.
- `UITextNode::mLayoutCharCount` syncs the character index between
  `rebuildRichText` (where boundary-stripped text is added) and
  `positionRichTextChildren` (where widgets are mapped to render spans),
  fixing hitbox alignment for all text-bearing widgets.
Remove `HTMLFormatter::collapseXmlWhitespace`, `isInlineNode`,
`hasSignificantText`, `getLogicalPrev`, `getLogicalNext`, and the
`precomputeDisplayStyles` hack (~200 lines removed). `HTMLFormatter`
now only exposes `HTMLtoXML`.
Other fixes
- Remove `@import url(https://fonts.googleapis.com/css?family=Lato)`
  from ensoft.css (async HTTP use-after-free in test suite).
- `BlockLayouter::positionRichTextChildren` skips whitespace-only text
  nodes when advancing the character index.
- Fix in DrawableSearcher not finding already loaded textures.
This commit is contained in:
Martín Lucas Golini
2026-05-08 22:13:07 -03:00
parent 7452cbf492
commit e94550a049
24 changed files with 2142 additions and 351 deletions

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head><style>
body { margin:0; padding:0; background:white; }
.tile { display:inline-block; width:32px; height:32px; }
</style></head>
<body>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) 0px 0px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -33px 0px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -66px 0px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -99px 0px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -132px 0px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) 0px -33px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -33px -33px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -66px -33px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -99px -33px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -132px -33px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) 0px -66px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -33px -66px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -66px -66px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -99px -66px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -132px -66px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) 0px -99px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -33px -99px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -66px -99px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -99px -99px / 1024px 512px no-repeat;"></div>
<div class="tile" style="background: url(../../../assets/atlases/bnb.png) -132px -99px / 1024px 512px no-repeat;"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -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" ),

View File

@@ -2,26 +2,12 @@
#define EE_UI_TOOLS_HTMLFORMATTER_HPP
#include <eepp/config.hpp>
#include <eepp/core/string.hpp>
namespace pugi {
class xml_node;
}
#include <string>
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 );
};

View File

@@ -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.
*

View File

@@ -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();

View File

@@ -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 );

View File

@@ -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

View File

@@ -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;

View File

@@ -5,6 +5,7 @@
#include <eepp/graphics/textureatlasmanager.hpp>
#include <eepp/graphics/texturefactory.hpp>
#include <eepp/system/base64.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/md5.hpp>
#include <eepp/network/http.hpp>
@@ -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 ) {

View File

@@ -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<UITextNode*>( node )->getText().length();
auto* tn = static_cast<UITextNode*>( node );
curCharIdx += tn->getLayoutCharCount();
return bounds;
}

View File

@@ -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<StyleSheetProperty> properties;
const std::vector<std::string>& propNames = shorthand->getProperties();
std::vector<std::string> 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<std::string> layers = String::split( value, ',' );
std::vector<std::string> imageValues;
std::vector<std::string> repeatValues;
std::vector<std::string> attachmentValues;
std::vector<std::string> originValues;
std::vector<std::string> clipValues;
std::vector<std::string> positionValues;
std::vector<std::string> sizeValues;
std::string colorValue;
for ( size_t layerIdx = 0; layerIdx < layers.size(); ++layerIdx ) {
std::string layerVal = String::trim( layers[layerIdx] );
std::vector<std::string> 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, ',' ) ) );
}
}

View File

@@ -1,4 +1,4 @@
#include <eepp/system/regex.hpp>
#include <eepp/core/containers.hpp>
#include <eepp/ui/tools/htmlformatter.hpp>
#include <string_view>
@@ -8,8 +8,6 @@
#include <gumbo-parser/gumbo.h>
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 `<p>` or `<div>`).
//
// For example:
// <p>
// <a href="...">
// <img />
// </a>
// </p>
// In this snippet, the spaces and newlines between `<p>` and `<a>` are completely
// dropped because they touch the block boundary of `<p>`. The spaces between `<a>` and
// `<img>` are inside an inline context, but because `<img/>` and `<a>` 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 (<img/>) or line breaks (<br/>).
// Empty inline elements (e.g. `<span></span>`) 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 `<span><b>text</b></span> <img/>`, the space is technically a sibling
// of `<span>`, but logically it follows `<b>text</b>`.
//
// `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 `<div>`), 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 (<br/>).
// 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 <br/>
// (because <br/> 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 "";

View File

@@ -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 )

View File

@@ -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<UINode*>( this ) );
if ( mFlags & UI_HTML_ELEMENT )
mBackground->setBackgroundMode( BackgroundMode::Html );
}
return mBackground;

View File

@@ -7,19 +7,73 @@
#include <eepp/ui/css/stylesheetspecification.hpp>
#include <eepp/ui/uinode.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiwidget.hpp>
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<RepeatY>( 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<UIWidget>()->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;
}

View File

@@ -2,7 +2,6 @@
#include <eepp/graphics/text.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/tools/htmlformatter.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uilayouter.hpp>
#include <eepp/ui/uirichtext.hpp>
@@ -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<UITextNode*>( node );
if ( !textNode->getText().empty() ) {
FontStyleConfig style;
if ( node->getParent()->isType( UI_TYPE_TEXTSPAN ) ) {
style = node->getParent()->asType<UITextSpan>()->getFontStyleConfig();
} else if ( node->getParent()->isType( UI_TYPE_RICHTEXT ) ) {
style =
node->getParent()->asType<UIRichText>()->getRichText().getFontStyleConfig();
} else {
style = richText.getFontStyleConfig();
// 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<UITextNode>();
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<UIWidget>()->isInlineDisplay() )
break;
}
return nullptr;
};
auto findLogicalNext = []( Node* n ) -> Node* {
while ( n ) {
Node* sib = n->getNextNode();
while ( sib && sib->isTextNode() ) {
auto* tn = sib->asType<UITextNode>();
if ( !tn->isWhitespaceOnly() )
return sib;
sib = sib->getNextNode();
}
if ( sib && sib->isWidget() )
return sib;
n = n->getParent();
if ( !n || !n->isWidget() || !n->asType<UIWidget>()->isInlineDisplay() )
break;
}
return nullptr;
};
if ( node->isTextNode() ) {
UITextNode* textNode = node->asType<UITextNode>();
String text = textNode->getText();
Node* prev = findLogicalPrev( node );
bool prevIsInline =
prev && prev->isWidget() && prev->asType<UIWidget>()->isInlineDisplay();
Node* next = findLogicalNext( node );
bool nextIsInline =
next && next->isWidget() && next->asType<UIWidget>()->isInlineDisplay();
// Strip leading space if prev is not inline (block boundary)
if ( !prevIsInline && !text.empty() && text[0] == ' ' )
text = text.substr( 1 );
// Strip trailing space if next is not inline
if ( !nextIsInline && !text.empty() && text.back() == ' ' )
text = text.substr( 0, text.size() - 1 );
if ( text.empty() ) {
textNode->setLayoutCharCount( 0 );
return;
}
textNode->setLayoutCharCount( text.length() );
FontStyleConfig style;
if ( node->getParent()->isType( UI_TYPE_TEXTSPAN ) ) {
style = node->getParent()->asType<UITextSpan>()->getFontStyleConfig();
} else if ( node->getParent()->isType( UI_TYPE_RICHTEXT ) ) {
style = node->getParent()->asType<UIRichText>()->getRichText().getFontStyleConfig();
} else {
style = richText.getFontStyleConfig();
}
richText.addSpan( text, style );
return;
}
@@ -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<UIWidget>()->isInlineDisplay();
Node* next = findLogicalNext( node );
bool nextIsInline =
next && next->isWidget() && next->asType<UIWidget>()->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 );

View File

@@ -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

View File

@@ -2,7 +2,6 @@
#include <eepp/graphics/text.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/tools/htmlformatter.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uitextnode.hpp>
@@ -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() );
}
}
}

View File

@@ -9,6 +9,7 @@
#include <eepp/ui/css/transitiondefinition.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
#include <eepp/ui/uieventdispatcher.hpp>
#include <eepp/ui/uihtmlwidget.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uistyle.hpp>
@@ -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<const UIHTMLWidget*>( 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;

View File

@@ -16,6 +16,7 @@
#include <eepp/ui/uihtmltable.hpp>
#include <eepp/ui/uihtmltextarea.hpp>
#include <eepp/ui/uihtmltextinput.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uithememanager.hpp>
@@ -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<UIWidget>()->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 );
}