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