diff --git a/.agent/plans/eepp_linux_build_time_optimization_plan.md b/.agent/plans/eepp_linux_build_time_optimization_plan.md deleted file mode 100644 index 63eeaa721..000000000 --- a/.agent/plans/eepp_linux_build_time_optimization_plan.md +++ /dev/null @@ -1,724 +0,0 @@ -# eepp Linux Build-Time Optimization Plan - -Status: **Planning complete; no implementation or benchmark run has started.** - -Last review: 2026-08-02. - -## 1. Objective - -Reduce clean and incremental Linux compilation time for eepp and ecode without lowering the -current optimization level and without degrading runtime performance, memory use, API quality, -or supported platforms. - -The work must be measurement-driven. Do not perform broad include removal, container replacement, -PImpl conversion, unity builds, or precompiled-header adoption without showing which measured cost -the change addresses and whether it improves the relevant end-to-end build. - -The primary machine for the initial investigation is: - -```text -AMD Ryzen 9 3900X -12 cores / 24 hardware threads -Linux -Clang 22.1.8 -Ninja -``` - -The repository supports Clang time tracing in both generators: `premake4 --time-trace` and -`premake5 --time-trace` both add `-ftime-trace` to compile commands. They serve different normal -workflows in this repository: - -- **debug and unit-test builds use Premake 4 with the GNU Make generator**, following - `.agent/rules/build-project.md`; -- **release build-time and runtime-performance investigations use Premake 5 with the Ninja - generator**, following the `eepp-linux-ninja` configuration in `.ecode/project_build.json`. - -Do not substitute one workflow for the other merely because both generators expose -`--time-trace`. Use Premake 5/Ninja for the primary release compilation-time baseline and Premake -4/GNU Make when specifically measuring or validating the normal debug workflow. - -## 2. Scope and non-goals - -### In scope - -- eepp library C++ translation units; -- ecode C++ translation units; -- eepp modules and tools when they are part of a measured developer workflow; -- public and private header fan-out; -- template parsing and instantiation cost; -- large generated language-syntax translation units; -- clean builds and representative incremental rebuilds; -- compiler-cache integration for normal developer builds; -- selective precompiled headers or unity builds if measurement justifies them; -- link-time measurement, while keeping compilation and linking results separate. - -### Out of scope unless evidence changes the decision - -- changing release or debug optimization levels; -- optimizing unchanged third-party C libraries; -- rewriting HarfBuzz or maintaining a private HarfBuzz fork; -- treating already up-to-date object files as a recurring build cost; -- replacing containers based only on header size or reputation; -- runtime-performance regressions in exchange for faster compilation; -- global PImpl conversion or other ABI-wide redesign; -- C++20 modules as an initial solution; -- using an AddressSanitizer build as the performance baseline. - -Third-party sources are already handled correctly by incremental dependency tracking: unchanged -objects are not rebuilt. Most third-party units are C and compile quickly. HarfBuzz is a known -heavy dependency, but is considered residual cost rather than an optimization target. Report its -time separately so it does not obscure improvements to project-owned code. - -## 3. Current evidence and hypotheses - -At the time this plan was written: - -- `make/linux/release_x86_64/compile_commands.json` contained 1,989 commands: 1,283 C++ and 706 C; -- generated commands invoked `clang` / `clang++` directly rather than consistently using ccache or - sccache; -- ccache, sccache, mold, and Ninja were installed on the machine; -- no project PCH or unity-build configuration was found; -- historical `.ninja_log` entries showed expensive project-owned objects including `ecode.o`, - `chatui.o`, `lspclientserver.o`, several ecode plugin objects, - `stylesheetspecification.o`, `uicodeeditor.o`, `uihtml_tests.o`, and generated language syntax - objects; -- `syntaxdefinitionmanager.hpp` was directly included by at least 169 C++ sources; -- large foundational public headers included `scene/node.hpp`, `ui/uinode.hpp`, `ui/uiwidget.hpp`, - `core/string.hpp`, `ui/uiscenenode.hpp`, `ui/uicodeeditor.hpp`, `network/http.hpp`, and - `ui/doc/textdocument.hpp`. - -These observations identify candidates, not conclusions. Ninja durations from a parallel build -include CPU and memory contention and must not be treated as isolated TU compile times. - -The leading hypotheses are: - -1. generated syntax-definition units repeatedly parse more manager/UI infrastructure than needed; -2. large ecode and UI source files receive expensive transitive dependency trees; -3. implementation-only types are exposed through common headers; -4. substantial inline or template code is instantiated repeatedly; -5. developer rebuilds are missing avoidable compiler-cache hits; -6. a carefully scoped PCH may reduce repeated standard-library and stable framework parsing; -7. selective unity grouping may help stable, homogeneous source families, but could hurt - incremental builds and peak memory. - -## 4. Required operating rules - -Before any work, read: - -```text -.agent/SOUL.md -.agent/rules/project-introduction.md -.agent/rules/build-project.md -.ecode/project_build.json -``` - -Also follow these rules: - -1. Run all Premake and build commands from the repository root unless the command explicitly uses - `-C make/linux`. -2. Record `git status --short` before touching files. Preserve all pre-existing user changes. -3. Do not commit, push, reset, or discard user work. -4. Regenerate project files after source or Premake changes, as required by the build rules. -5. Format every modified C/C++ file with `clang-format` before compilation. -6. Keep benchmark artifacts outside tracked source directories, preferably under - `/tmp/eepp-build-time-/`. -7. Do not mix ASan results with build-time baseline results. -8. Do not compare runs generated with different compilers, flags, backends, target sets, cache - modes, or background load. -9. Repeat important measurements at least three times and report the median. If variance exceeds - 5%, investigate noise before claiming a small improvement. -10. Separate clean-build, no-op, incremental, cache-hit, and isolated-TU results. -11. Preserve `-O3` for release experiments. Build-time changes must not come from reducing - optimization. -12. After every structural C++ change, run an allocation and runtime-performance audit as required - by `.agent/SOUL.md`. -13. Preserve the repository's generator split: Premake 4/GNU Make for normal debug and unit-test - workflows, and Premake 5/Ninja for release build-time investigations. Do not compare their - timings as though they were the same build configuration. - -## 5. Benchmark matrix - -Do not use a single `ninja release` duration as the only metric. Establish the following named -workflows. - -| ID | Workflow | Purpose | -|---|---|---| -| B1 | clean eepp-owned library build | framework clean-build cost | -| B2 | clean ecode build including required dependencies | real application clean-build cost | -| B3 | no-op repeat of B2 | generator/dependency overhead sanity check | -| B4 | edit/touch one leaf `.cpp`, rebuild ecode | common local edit latency | -| B5 | touch a widely used eepp core header, rebuild | public-header blast radius | -| B6 | touch a widely used UI header, rebuild | UI dependency blast radius | -| B7 | touch syntax-definition interface, rebuild | generated syntax fan-out | -| B8 | isolated compilation of each top slow C++ TU | remove parallel contention | -| B9 | cache-enabled rebuild after removing only selected outputs | compiler-cache benefit | -| B10 | link-only relink | keep link cost separate from compile cost | -| B11 | normal Premake 4/GNU Make debug build and representative incremental rebuild | ensure improvements also help or at least do not harm the debug workflow | - -The executing agent must first inspect `make/linux/build.ninja` with `ninja -t targets` and identify -the exact target names for eepp, ecode, and their configurations. Do not guess target names. Store -the resolved commands in the benchmark report. - -For header invalidation tests, prefer `touch` followed by restoring the original timestamp if that -can be done safely, or make a reversible whitespace change in a clean file. Never overwrite user -changes. Record the exact header and why it represents the workflow. - -## 6. Phase 0: Prepare the investigation - -### Tasks - -1. Capture repository state and tool versions: - - ```bash - git status --short - premake4 --version - premake5 --version - clang --version - clang++ --version - ninja --version - ccache --version - sccache --version - mold --version - nproc - lscpu - ``` - -2. Re-read `.ecode/project_build.json` and use `eepp-linux-ninja` as the source of truth. -3. Inspect available Ninja targets: - - ```bash - ninja -C make/linux -t targets all - ``` - -4. Inspect representative compile commands and confirm: - - compiler; - - `-O3` in release; - - debug-symbol setting; - - SDL backend; - - architecture; - - whether ccache/sccache is actually in the command; - - whether the target compiles only required dependencies or the whole workspace. -5. Confirm both time-trace options remain defined before relying on them: - - ```bash - premake4 --help | rg 'time-trace' - premake5 --help | rg 'time-trace' - ``` - -6. Create an untracked results directory under `/tmp`, containing: - - `environment.txt`; - - `commands.txt`; - - `baseline.tsv`; - - `traces/`; - - `reports/`. - -### Exit criteria - -- Exact target names are known. -- Benchmark commands are reproducible. -- Existing user changes are documented and protected. -- No source code has changed. - -## 7. Phase 1: Establish baselines - -### Build generation - -Use the current non-ASan Premake 5/Ninja configuration for the primary release baseline: - -```bash -premake5 --disable-static-build --with-debug-symbols --with-backend=SDL3 ninja -``` - -If `.ecode/project_build.json` changes before execution, follow the updated configuration instead -and document the difference. - -The normal debug workflow is separate. Generate it with Premake 4 and GNU Make, using the exact -current command prescribed by `.agent/rules/build-project.md` and including the conditional mold -flag when required. This debug build may use AddressSanitizer because that is the project's normal -debug/test configuration, but never use its timings as release-performance measurements or compare -them directly with the Premake 5/Ninja release baseline. - -### Timing method - -Use `/usr/bin/time` so wall time, CPU time, and maximum resident set size are captured. A template -is: - -```bash -/usr/bin/time -f 'wall=%e user=%U sys=%S cpu=%P maxrss_kb=%M exit=%x' \ - ninja -C make/linux -``` - -Run each meaningful baseline three times under the same conditions. For clean builds, use the -narrowest safe Ninja clean operation for the measured target/configuration. Inspect the clean -command before running it; do not delete the repository or broad directories manually. - -Record: - -- run ID and timestamp; -- exact generation and build command; -- cold/warm filesystem-cache state, without forcibly dropping kernel caches; -- compiler-cache enabled/disabled state; -- wall, user, system, CPU%, and peak RSS; -- number of commands executed; -- target result size and link duration where available; -- relevant system load and CPU frequency governor. - -Do not use `ninja -d stats` output as a replacement for wall-clock timing, but capture it where -useful. - -### Analyze historical Ninja data - -Use `.ninja_log` only as a candidate generator. Since it can contain repeated historical entries, -group by output and report the latest run or distribution rather than blindly taking the maximum. - -Suggested extraction starting point: - -```bash -awk 'NR > 1 && $2 >= $1 { print $2-$1, $4 }' make/linux/.ninja_log | sort -nr -``` - -Classify entries as: - -- project-owned eepp; -- project-owned ecode/tools/modules/tests; -- generated syntax definitions; -- HarfBuzz; -- other third-party C/C++; -- linking. - -### Exit criteria - -- B1 through B7 and B11 have commands and initial measurements, with debug and release results - kept separate. -- No-op B3 executes zero unexpected compiler commands. -- Third-party and link costs are separated from project-owned compilation. -- Variance is understood well enough to evaluate later changes. - -## 8. Phase 2: Capture and aggregate Clang time traces - -### Generate traced build files - -Regenerate the primary release trace build with Premake 5 while preserving all baseline options: - -```bash -premake5 --disable-static-build --with-debug-symbols --with-backend=SDL3 --time-trace ninja -``` - -Confirm a representative C++ compile command contains both `-O3` and `-ftime-trace`. - -If tracing the debug workflow as a separate investigation, add `--time-trace` to the current -Premake 4 debug generation command from `.agent/rules/build-project.md`, then build with GNU Make. -Keep those trace reports in a separate `debug-premake4/` results directory. Debug/ASan traces may -identify dependency fan-out, but their total durations and optimization/backend costs are not -comparable to the Premake 5 release traces. - -Run a clean, scoped traced build. Trace instrumentation adds overhead, so do not compare traced -wall time directly with the untraced baseline. Its purpose is attribution. - -Before compiling, determine where this Clang version writes trace JSON files. Copy them to the -temporary results directory after the build while retaining a mapping from trace to source/object. -Do not add trace JSON files to Git. - -### Aggregate these categories - -For every C++ trace, extract at least: - -- total compiler duration; -- frontend duration; -- backend/optimizer duration; -- source/header parsing totals; -- template instantiation totals; -- code generation totals; -- expensive individual headers; -- expensive template specializations where Clang names them. - -Produce these reports: - -```text -reports/tu-total.tsv -reports/frontend.tsv -reports/backend.tsv -reports/header-self.tsv -reports/header-cumulative.tsv -reports/template-instantiation.tsv -reports/project-vs-third-party.tsv -``` - -If no existing repository tool aggregates traces adequately, create a small standalone analysis -script under the temporary results directory first. Only add a reusable script to the repository -later if it proves valuable. The parser must stream or process files one at a time rather than load -all trace files into memory simultaneously. - -### Required ranking method - -Rank headers by both: - -1. expensive appearance in one TU; -2. cumulative cost across all project-owned TUs. - -Also record include fan-out. A 100 ms header parsed in 200 units is usually more valuable than a -one-second header parsed once. - -### Isolated TU confirmation - -For the top 10–20 project-owned C++ objects, extract the exact compile command from -`compile_commands.json` or Ninja and execute it serially with `/usr/bin/time`. Preserve its output -path safely or direct experimental output into `/tmp`; do not corrupt normal build dependencies. - -Run each top candidate at least three times. This distinguishes intrinsic cost from contention in -the original parallel build. - -### Exit criteria - -- At least 80% of project-owned C++ compilation time is classified by subsystem or candidate. -- Top headers are ranked by cumulative cost and fan-out. -- Top slow TUs have isolated measurements. -- Frontend-heavy and backend-heavy candidates are separated. - -## 9. Phase 3: Header dependency and syntax-definition investigation - -This is the first source-level optimization phase. - -### 9.1 Generated syntax definitions - -Start here if traces confirm the current hypothesis. - -Inspect the generated language source family and answer: - -- Why does each source include `syntaxdefinitionmanager.hpp`? -- Does registration require the full manager definition? -- Can definition construction use a small declaration/value header? -- Can manager registration move to one aggregation `.cpp`? -- Are large initializer expressions causing frontend or backend cost? -- Are identical template specializations emitted repeatedly? -- Can data be expressed in a representation that compiles faster without adding startup work, - heap churn, or runtime parsing? - -Preferred low-risk direction: - -```text -small syntax-definition declaration/data interface - -> individual generated language units -full manager implementation - -> one or a few aggregation/registration units -``` - -Do not merge all languages into one huge source unless an isolated experiment shows acceptable -incremental behavior and memory use. - -### 9.2 High cumulative-cost headers - -For every top header, use preprocessing/include-tree tools to identify why it is present. Useful -commands include the exact compile command augmented with one of: - -```text --H --E --ftime-trace -``` - -Investigate: - -- includes needed only by `.cpp` implementation; -- pointer/reference members that can use forward declarations; -- inline functions whose definitions require heavy dependencies; -- nested type references that force full includes; -- callbacks using `std::function` in ubiquitous public APIs; -- private concrete containers exposed in class layout; -- umbrella/convenience headers included by lower layers; -- templates that can be explicitly instantiated; -- duplicated helper templates or traits. - -### Change rules - -Make one logical dependency change per benchmarkable patch. For each change: - -1. record the baseline candidate metrics; -2. implement the smallest correction; -3. regenerate build files; -4. rebuild the affected target; -5. run relevant tests; -6. repeat isolated TU measurement; -7. repeat the relevant clean/incremental benchmark; -8. record runtime/allocation/API consequences; -9. revert changes that do not produce a repeatable useful gain. - -### PImpl warning - -Do not introduce PImpl solely to hide includes when it adds per-object allocation or indirection to -hot eepp types. Prefer, in order: - -1. forward declaration without layout changes; -2. moving out-of-line function bodies; -3. small non-owning interface types; -4. splitting stable data from heavy behavior; -5. PImpl only for cold, coarse-grained objects where allocation and ABI tradeoffs are acceptable. - -### Exit criteria - -- At least the top five cumulative project header costs have been explained. -- The syntax-definition hypothesis has either produced a measured improvement or been rejected - with evidence. -- Accepted changes improve B1/B2/B5/B6/B7 as applicable, not merely preprocessing byte count. -- All affected tests pass. - -## 10. Phase 4: Template and container cost - -Only begin this phase if time traces show meaningful template parsing or instantiation cost. - -### Investigate first - -- which exact templates dominate; -- number of unique versus repeated specializations; -- whether cost is parsing, instantiation, optimization, or debug information; -- whether the specialization is required in public headers; -- whether explicit instantiation is legal and useful; -- whether a non-template interface boundary would preserve runtime performance. - -### Candidate techniques - -- `extern template` declarations plus explicit instantiation in one `.cpp`; -- moving template-heavy operations out of common headers; -- reducing accidental type variation that creates near-duplicate specializations; -- replacing a container only when both build-time traces and runtime requirements support it; -- using spans/views at API boundaries to avoid exporting container implementation choices. - -Do not assume eepp's `UnorderedMap` or `UnorderedSet` is compile-time cheaper merely because it is -runtime-preferred. `include/eepp/thirdparty/unordered_dense.h` is itself substantial. Compare small -representative compilations and affected end-to-end targets before changing types. - -### Acceptance gate - -A container/template change is accepted only if: - -- it improves a named build metric beyond noise; -- runtime benchmarks do not regress materially; -- memory/allocation behavior is equal or better, or a tradeoff is explicitly approved; -- public API and serialization behavior remain correct; -- cross-platform compilers remain supported. - -## 11. Phase 5: Compiler-cache integration - -This phase improves developer workflow but must be reported separately from structural cold-build -improvements. - -### Procedure - -1. Inspect how Premake's Ninja generator selects `CC` and `CXX`. -2. Prototype ccache first because the machine already has it and it is straightforward locally. -3. Prefer a compiler launcher or generated-command prefix over replacing the compiler identity in - a way that breaks dependency generation. -4. Confirm commands actually invoke ccache. -5. Clear only the experimental cache namespace when a cold-cache test is required; do not erase a - user's global cache without explicit permission. -6. Record `ccache -z`, run the workflow, then record `ccache -s`. -7. Test: - - empty-cache clean build; - - immediate rebuild after removing selected objects; - - rebuild after a source edit and revert; - - rebuild after switching between debug and release; - - cache invalidation after a common header changes. -8. Compare cache overhead and hit rate. - -Evaluate sccache only if remote/shared caching or its operational model is desired. Do not enable -both simultaneously. - -### Acceptance gate - -- no dependency correctness regressions; -- no stale-object behavior; -- measurable warm-build benefit; -- negligible cold-cache regression; -- documented opt-in/default policy; -- structural benchmark results remain available with cache disabled. - -## 12. Phase 6: Selective precompiled-header experiment - -Attempt PCH only after trace aggregation identifies a stable common header prefix. - -### Candidate selection - -A PCH candidate should be: - -- parsed by many TUs; -- expensive cumulatively; -- stable across ordinary edits; -- compatible across all commands in the target group; -- mostly standard-library or stable project configuration headers; -- free of order-dependent macros and per-TU configuration. - -Do not place frequently edited eepp UI headers into the first PCH. - -Prototype separate PCH scopes where appropriate: - -- eepp core/library; -- ecode; -- generated syntax definitions. - -### Measure - -- clean target build; -- leaf `.cpp` incremental build; -- common-header invalidation rebuild; -- PCH-generation time; -- peak memory at `-j24`, `-j12`, and one lower concurrency if memory pressure appears; -- binary output and runtime parity. - -### Acceptance gate - -Keep a PCH only when end-to-end benefit remains significant after including PCH generation and its -invalidation cost. Avoid a PCH that improves clean builds but makes the dominant edit/rebuild loop -worse. - -## 13. Phase 7: Selective unity-build experiment - -Unity builds are optional and lower priority than dependency hygiene and PCH. - -Good initial candidates are homogeneous, stable source families with shared includes, especially -generated syntax definitions if Phase 3 shows repeated frontend cost. - -Do not begin with a monolithic eepp or ecode unity file. - -### Required checks - -- static/anonymous namespace symbol collisions; -- macro leakage and include-order dependence; -- warning changes; -- peak compiler memory; -- loss of parallelism; -- incremental rebuild amplification; -- debug experience and source attribution; -- generated-code update behavior. - -Test multiple group sizes rather than only on/off. Compare clean time and leaf-edit latency at -realistic parallelism. - -### Acceptance gate - -Unity mode should be optional unless it improves both the dominant developer workflow and clean -builds without unacceptable memory, diagnostics, or incremental penalties. - -## 14. Phase 8: Parallelism and linker tuning - -This phase does not change optimization levels and should be done after source improvements. - -### Parallelism sweep - -Run the chosen clean target with at least: - -```text --j8 --j12 --j16 --j20 --j24 -``` - -Measure wall time and peak RSS. The fastest setting on a 3900X may be below 24 because large Clang -jobs compete for memory bandwidth and cache. Recommend the best default separately for clean and -incremental builds if they differ. - -### Linker - -Measure mold versus the current linker only for B10 and full target wall time. Linker selection -does not explain compilation hotspots. If mold is already active, simply document the residual -link fraction. - -## 15. Validation after every accepted code change - -For C/C++ edits: - -```bash -git diff --name-only -- '*.c' '*.cpp' '*.h' '*.hpp' | xargs clang-format -i -``` - -Regenerate the release build using the current approved Premake 5/Ninja command and compile the -narrow affected target. Regenerate debug/tests using the current Premake 4/GNU Make command, then -run focused unit tests followed by the full relevant unit-test suite when the phase is ready. - -At minimum verify: - -- clean build succeeds; -- incremental dependencies rebuild everything required and nothing obviously unrelated; -- debug and release configurations compile when the change affects shared build logic; -- no warnings were introduced; -- unit tests pass; -- public headers remain self-contained where expected; -- binary behavior and runtime performance remain unchanged; -- allocation audit is complete; -- `git diff --check` passes. - -For Premake changes, inspect generated commands rather than assuming the intended flag or launcher -was applied. - -## 16. Reporting format - -Maintain one result table for accepted and rejected experiments: - -| Experiment | Metric | Before median | After median | Delta | Variance | Decision | -|---|---|---:|---:|---:|---:|---| -| example | B2 wall | 100.0 s | 91.0 s | -9.0% | 1.2% | accept | - -Each experiment report must include: - -- hypothesis; -- exact files changed; -- exact commands; -- machine/environment differences; -- affected trace categories; -- clean and incremental results; -- peak-memory result; -- tests run; -- runtime/allocation considerations; -- decision and rationale. - -Track improvements cumulatively, but periodically rerun the original baseline command from the -same branch state to detect benchmark drift. - -## 17. Final deliverables - -The executing agent should produce: - -1. a reproducible benchmark script or documented command set; -2. baseline results for B1 through B11 where applicable, with Premake 4 debug and Premake 5 release - results clearly separated; -3. aggregated Clang trace reports; -4. a ranked list of project-owned TU, header, and template costs; -5. accepted source/build-system improvements, each independently measured; -6. a list of rejected experiments and why they failed; -7. recommended cache configuration for normal development; -8. recommended Ninja job count for the 3900X; -9. final clean-build and incremental-build comparison; -10. remaining known costs, including HarfBuzz, clearly separated from actionable eepp costs. - -## 18. Execution order and stop conditions - -Execute in this order: - -```text -Phase 0 environment and targets - -> Phase 1 reproducible baselines - -> Phase 2 trace aggregation and isolated confirmation - -> Phase 3 dependency/syntax refactoring - -> Phase 4 template/container work if justified - -> Phase 5 compiler cache - -> Phase 6 selective PCH if justified - -> Phase 7 selective unity if justified - -> Phase 8 parallelism/link tuning - -> final validation and report -``` - -Stop or request guidance when: - -- existing user changes overlap a required file and cannot be preserved safely; -- the active build configuration differs materially from this plan; -- a proposed change adds runtime allocation or indirection to a hot type; -- public API/ABI changes appear necessary; -- a measurement cannot be reproduced within reasonable variance; -- a change improves one workflow but materially harms a more important workflow; -- completing an experiment would require destructive cache or build-directory deletion not - explicitly authorized. - -Do not declare success based on trace reduction alone. Success means repeatably lower wall time in -the named developer workflows, with correct builds and no unacceptable runtime or maintenance -cost. diff --git a/.agent/plans/html_dynamic_non_atomic_inline_richtext_plan.md b/.agent/plans/html_dynamic_non_atomic_inline_richtext_plan.md new file mode 100644 index 000000000..f27430e79 --- /dev/null +++ b/.agent/plans/html_dynamic_non_atomic_inline_richtext_plan.md @@ -0,0 +1,258 @@ +# Dynamic Non-Atomic Inline `UIRichText` Plan + +Status: proposed follow-up to the HTML replaced-element and auto-margin refactor, 2026-08-03. + +## Goal + +Support HTML elements represented by `UIRichText` when their computed `display` changes to +`inline`, without treating them as atomic inline widgets and without relying on `UITextSpan` as the +only representation of a non-atomic inline box. + +The end state should provide: + +- correct non-atomic inline participation for any eligible rich-text HTML box whose computed outer + display is `inline`; +- runtime block-to-inline and inline-to-block transitions without replacing the DOM widget; +- one type-safe rich-text bridge for inline fragment generation, independent of the concrete + `UITextSpan` class; +- preserved text styling, whitespace processing, backgrounds, borders, hit boxes, and event source + identity across display changes; +- continued atomic handling for replaced elements, inline-block, inline-flex, inline-grid, floats, + and out-of-flow boxes. + +This work must remain separate from replaced-image mechanics. `UIHTMLImage` is an inline-level +replaced element and must continue to enter the atomic-box path. + +## Governing CSS Concept + +The generic concept is the distinction between an element's outer display role and whether its +principal box is atomic or non-atomic in an inline formatting context. + +Relevant standards: + +- CSS Display Level 3, inner and outer display types and box generation; +- CSS 2.2 section 9.2.2, inline-level elements and inline boxes; +- CSS 2.2 section 9.4.2, inline formatting contexts; +- CSS 2.2 section 8.6, inline element boxes and fragmented padding, borders, and margins; +- CSS Text Level 3, white-space processing across inline element boundaries; +- CSS 2.2 sections 9.7 and 10.3.9, blockification and atomic inline-level boxes. + +Fixture-independent invariant: changing a normal rich-text HTML element from `display:block` to +`display:inline` makes its text and inline descendants participate in the containing line stream, +while preserving its own inline fragment decorations and DOM identity. It must not become a single +`RichText::CustomBlock`. + +## Current State and Problem + +`UIRichText::rebuildRichText()` currently recognizes a non-atomic inline container only when the +widget is a `UITextSpan`: + +```cpp +widget->isType( UI_TYPE_TEXTSPAN ) && widget->asType()->isInline() +``` + +That branch then calls `UITextSpan`-specific APIs for: + +- text and font-style access; +- layout character counts; +- inline background, border, decoration, and baseline metadata; +- whitespace and text-transform processing; +- child traversal and inline-box push/pop; +- inline-block follow-up behavior. + +The type restriction is currently necessary. A broader `UI_TYPE_HTML_WIDGET` test would admit +inline replaced elements such as `UIHTMLImage` and then perform an invalid `UITextSpan` cast. + +However, an element instantiated as `UIRichText` (for example a `div` or paragraph) remains a +`UIRichText` when CSS changes its display. `UIHTMLWidget::setDisplay()` exchanges its layouter and +size policy but not its concrete widget type. When such a box becomes `display:inline`, +`UILayouterManager` currently gives it a `BlockLayouter`, and the parent rich-text rebuild treats it +as an atomic custom block. This does not model a normal non-atomic inline box. + +## Design Direction + +### Classify inline participation explicitly + +Introduce a centralized inline participation classification derived from computed CSS state and +box capabilities, for example: + +```cpp +enum class CSSInlineParticipation { + None, + NonAtomicContainer, + AtomicBox +}; +``` + +The exact API is an implementation decision. It should live near `UIHTMLWidget` / formatting-role +classification and consider: + +- computed outer display; +- float and out-of-flow state; +- flex/grid blockification; +- replaced-element identity; +- whether the widget can expose rich-text inline-container content. + +Do not infer non-atomic behavior from `SizePolicy`, tag name, or `UI_TYPE_TEXTSPAN` alone. + +### Extract a type-safe inline-content interface + +Move the data needed by `UIRichText::rebuildRichText()` behind a narrow interface or virtual API +implemented by rich-text-backed HTML widgets. Possible shapes include an +`UIInlineContentProvider` interface or protected/public virtual methods on `UIRichText`. + +The interface should expose only semantics required by the parent inline formatting context: + +- font style and optional owned text run; +- layout character-count reset/update when applicable; +- inline fragment background, border, padding, margin, decoration, and baseline alignment; +- effective white-space and text-transform inputs; +- logical children for recursive stream construction; +- source widget identity for painting, hit testing, and events. + +Prefer moving generally valid style accessors from `UITextSpan` into `UIRichText` over duplicating +state or conditionally casting. Keep span-only hit-box conveniences in `UITextSpan` if they are not +required by all inline containers. + +### Keep atomic and non-atomic paths separate + +The parent rich-text stream must continue to distinguish: + +- non-atomic `display:inline` rich-text containers: push an inline fragment box, recursively append + text/children, then pop the box; +- atomic inline-level boxes: append one `RichText::CustomBlock` / atomic box; +- block-level boxes: append the supported block representation and line breaks; +- floats and positioned boxes: retain their formatting-context-specific paths. + +`inline-block`, `inline-flex`, and `inline-grid` are atomic even when backed by `UIRichText`. +Replaced elements such as `UIHTMLImage` and form controls remain atomic at `display:inline`. + +### Preserve DOM objects during display mutation + +Do not replace a `UIRichText` instance with a `UITextSpan` when `display` changes. Replacing the +widget would risk losing references, listeners, focus, animation state, inspector identity, child +ownership, and author-script-visible state. The same widget should change formatting +participation in place. + +## Implementation Stages + +### Stage 1 - Lock Down the Unsupported Behavior + +Add focused tests before generalizing the implementation: + +- a block-created `div` styled `display:inline` between text siblings shares their line; +- nested inline children remain in document order and wrap as one inline stream; +- runtime `block -> inline -> block` mutation updates layout and paint ownership; +- inline padding, border, background color, and background image fragment across wrapped lines; +- whitespace collapse and preservation across the newly inline container's boundaries; +- an inline `UIHTMLImage` in the same content remains an atomic box; +- `inline-block`, floated, absolute, flex-item, and grid-item rich-text boxes remain atomic or + blockified as required; +- hit testing/event source identity continues to reference the original DOM widget. + +Use numeric layout invariants and render-span types where available. Avoid website-specific +fixtures as the only proof. + +### Stage 2 - Introduce Inline Participation Classification + +- Add the centralized inline participation query. +- Define replaced/atomic capability explicitly rather than through concrete image type checks. +- Incorporate parent flex/grid blockification and float/out-of-flow state before ordinary inline + display classification. +- Use the query in `UIRichText::rebuildRichText()` and + `BlockLayouter::positionRichTextChildren()` so stream generation and fragment-to-widget mapping + cannot disagree. +- Document which unsupported CSS display combinations intentionally fall back to atomic behavior. + +### Stage 3 - Generalize Rich-Text Inline Container Data + +- Inventory every `UITextSpan` method/state read by the current non-atomic inline branch. +- Move generally applicable font/style/text-fragment access to `UIRichText`, or expose it through a + narrow inline-content provider. +- Split optional owned text from descendant text nodes. A `UIRichText` with no direct text must + still generate its fragment box and recursively contribute its children. +- Keep layout character-count bookkeeping conditional where it is meaningful; do not add dummy + span state to all rich-text boxes solely to satisfy the old implementation. +- Replace repeated casts in the branch with one validated provider/reference. +- Generalize inline background/border helper signatures from `UITextSpan*` to the narrowest valid + rich-text/HTML type. + +### Stage 4 - Route Dynamic `UIRichText` Through Inline Formatting + +- Update `UIRichText::rebuildRichText()` to push/pop inline boxes for every classified non-atomic + inline container. +- Ensure recursive traversal skips out-of-flow descendants exactly as before. +- Preserve whitespace state across element boundaries and nested inline boxes. +- Update `UILayouterManager::create()` so non-atomic inline rich-text containers receive the + no-op/inline-owned layout behavior instead of an independent `BlockLayouter`. +- Ensure `UIHTMLWidget::onDisplayChange()` invalidates both the old and new formatting owners and + clears stale block geometry, fragment metadata, and hit boxes. +- Update `BlockLayouter::positionRichTextChildren()` to map all non-atomic inline fragment sources + back to widgets without `UITextSpan`-only assumptions. + +### Stage 5 - Resolve Paint and Hit-Test Ownership + +- Generalize the rule that non-atomic inline containers are painted by the ancestor `RichText` + stream, not by their own `UIRichText::draw()` call. +- Prevent duplicate text, background, and border painting during and after display mutation. +- Rebuild per-fragment hit boxes for generalized inline containers, or explicitly separate widget + aggregate bounds from span-specific text hit boxes. +- Verify hover, click, selection/source lookup, inspector bounds, and anchor behavior across wrapped + fragments. +- Clear inline fragment state when a box becomes block, atomic, floated, or out-of-flow. + +### Stage 6 - Audit Special Formatting Contexts + +- Verify flex/grid children are blockified before non-atomic inline classification. +- Verify `display:inline-flex` and `display:inline-grid` stay atomic in the parent line while owning + their internal formatting contexts. +- Verify table-internal roles are not accidentally flattened into text. +- Verify list-item markers remain associated with the correct principal box after display changes. +- Verify inline formatting inside table cells, details/summary, anchors, labels, and form wrappers. +- Confirm `UIHTMLImage`, native `UIImage`, and `UISvg` behavior is unchanged. + +### Stage 7 - Validation and Performance Audit + +- Run focused rich-text, inline-block, flex, grid, table, details, image, and WebView tests. +- Run the complete unit-test suite through `projects/scripts/xvfb-run-eepp`. +- Exercise repeated runtime display toggles and stylesheet replacement under ASan. +- Assert layout convergence after each mutation; no stale block size may feed the next inline pass. +- Audit new abstractions for allocations. Classification and steady-state rebuild traversal should + add no heap allocation beyond fragment data already required by `RichText`. +- Search the final layout diff for tag names, fixture selectors, unexplained constants, concrete + image checks, and unsafe downcasts. + +## Compatibility and Migration Risks + +- `UIRichText::draw()` currently assumes most non-`UITextSpan` instances own their paint. Changing + that ownership without synchronized parent-stream metadata can cause duplicate or missing text. +- `UITextSpan` carries direct text and font-style APIs not currently exposed uniformly by + `UIRichText`; moving them can affect serialization and native callers. +- Inline boxes fragment across lines, so a single widget rectangle is insufficient for precise + backgrounds, borders, and hit testing. +- Display changes can occur after deferred stylesheets load. Old layouters and fragment metadata + must not survive the transition. +- Flex/grid blockification takes precedence over the specified inline outer display. +- A generic `display:inline` test must not flatten replaced elements or atomic inline formatting + contexts. +- Anonymous text and whitespace processing depend on logical sibling order, not widget layout + order; recursive generalization must preserve that ordering. + +## Exit Criteria + +This follow-up is complete when: + +- an eligible `UIRichText` with computed `display:inline` participates as a non-atomic inline + container regardless of its construction-time widget class; +- runtime block/inline transitions preserve the DOM widget and converge without stale geometry; +- `UIRichText::rebuildRichText()` no longer uses `UI_TYPE_TEXTSPAN` as the definition of a + non-atomic inline box; +- the inline branch contains no unsafe or conditionally invalid `UITextSpan` casts; +- stream construction, positioning, painting, and hit testing use the same centralized inline + participation classification; +- replaced elements, inline-block/flex/grid, floats, positioned boxes, and flex/grid items retain + their atomic or blockified behavior; +- focused mutation/fragment/whitespace tests and the full unit-test suite pass under the required + wrapper; +- the performance audit finds no new steady-state heap work and the final diff contains no + fixture-specific layout rules. diff --git a/include/eepp/ui/uihtmlimage.hpp b/include/eepp/ui/uihtmlimage.hpp index 74dda138d..0b1079a1a 100644 --- a/include/eepp/ui/uihtmlimage.hpp +++ b/include/eepp/ui/uihtmlimage.hpp @@ -1,11 +1,15 @@ #ifndef EE_UI_UIHTMLIMAGE_HPP #define EE_UI_UIHTMLIMAGE_HPP -#include +#include +#include +#include +#include namespace EE { namespace UI { -class EE_API UIHTMLImage : public UIImage { +/** HTML replaced image element. CSS box/layout behavior comes from UIHTMLWidget. */ +class EE_API UIHTMLImage : public UIHTMLWidget { public: static UIHTMLImage* New(); @@ -19,13 +23,80 @@ class EE_API UIHTMLImage : public UIImage { virtual void draw(); + virtual void setAlpha( const Float& alpha ); + + const DrawablePtr& getDrawable() const; + + UIHTMLImage* setDrawable( DrawablePtr drawable ); + + UIHTMLImage* setDrawable( TexturePtr texture ); + + const Color& getColor() const; + + UIHTMLImage* setColor( const Color& col ); + + const Vector2f& getAlignOffset() const; + + virtual bool applyProperty( const StyleSheetProperty& attribute ); + + virtual Float getMinIntrinsicWidth() const; + + virtual Float getMaxIntrinsicWidth() const; + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const; + + virtual void scheduledUpdate( const Time& time ); + + virtual void updateLayout(); + + virtual std::vector getPropertiesImplemented() const; + + const UIScaleType& getScaleType() const; + + UIHTMLImage* setScaleType( const UIScaleType& scaleType ); + const std::string& getAlt() const; UIHTMLImage* setAlt( const std::string& alt ); + virtual bool isInline() const; + protected: UIHTMLImage(); + virtual void onSizeChange(); + + virtual void onSizePolicyChange(); + + virtual void onAlignChange(); + + virtual void onParentSizeChange( const Vector2f& sizeChange ); + + virtual void onDisplayChange(); + + void autoSizeImage(); + + void calcDestSize(); + + void clearDrawable(); + + void onDrawableResourceChange(); + + bool loadFileDrawable( const Network::URI& uri ); + + void loadRemoteDrawable( const Network::URI& uri ); + + UIScaleType mScaleType{ UIScaleType::Expand }; + DrawablePtr mDrawable; + Color mColor; + Vector2f mAlignOffset; + Vector2f mDestSize; + DrawableResourceConnection mResourceChangeConnection; + Uint32 mSpriteChangeCb{ 0 }; + bool mDeferLoad{ false }; + std::shared_ptr> mAsyncImageAlive; + Uint64 mRemoteImageLoadId{ 0 }; std::string mAlt; }; diff --git a/include/eepp/ui/uihtmlwidget.hpp b/include/eepp/ui/uihtmlwidget.hpp index 351b6ce60..ff8835a8b 100644 --- a/include/eepp/ui/uihtmlwidget.hpp +++ b/include/eepp/ui/uihtmlwidget.hpp @@ -13,6 +13,23 @@ namespace EE { namespace UI { class UILayouter; +enum class CSSFormattingRole : Uint8 { + Inline, + InlineBlock, + NormalFlowBlock, + Float, + Absolute, + Fixed, + FlexItem, + GridItem, + Table +}; + +struct CSSUsedMargins { + Rectf value; + Uint8 autoSides{ 0 }; +}; + struct UIHTMLWidgetFlexState { CSSFlexDirection direction{ CSSFlexDirection::Row }; CSSFlexWrap wrap{ CSSFlexWrap::NoWrap }; @@ -98,12 +115,13 @@ class EE_API UIHTMLWidget : public UILayout { void setBoxSizing( CSSBoxSizing boxSizing ); - Rectf getNormalFlowLayoutPixelsMargin() const; + CSSFormattingRole getFormattingRole() const; - /** Returns the used CSS margin for a child participating in a block formatting context. - * Horizontal auto margins are resolved only for normal-flow block-level boxes. For - * inline-level, floated, and out-of-flow boxes every auto margin has a used value of zero. */ - static Rectf getFormattingContextLayoutPixelsMargin( UIWidget* widget ); + /** Returns stack-local used margins for the current formatting role. This never mutates the + * computed/resolved margins stored by UIWidget. Flex, grid, and positioned layout retain their + * module-specific auto-margin distribution; callers in those contexts receive zero for auto + * sides until the owning layouter solves them. */ + CSSUsedMargins resolveUsedMargins() const; const CSSBaselineAlignValue& getBaselineAlign() const { return mBaselineAlign; } diff --git a/src/eepp/ui/blocklayouter.cpp b/src/eepp/ui/blocklayouter.cpp index cde67c964..88330d66c 100644 --- a/src/eepp/ui/blocklayouter.cpp +++ b/src/eepp/ui/blocklayouter.cpp @@ -151,7 +151,7 @@ void BlockLayouter::updateLayout() { auto* childWidget = child->asType(); if ( childWidget->isVisible() && !childWidget->isOutOfFlow() && childWidget->getCSSFloat() != CSSFloat::None ) { - const Rectf margin = childWidget->getNormalFlowLayoutPixelsMargin(); + const Rectf margin = childWidget->resolveUsedMargins().value; const Vector2f pos = childWidget->getPixelsPosition(); const Sizef size = childWidget->getPixelsSize(); contentSize.setWidth( @@ -526,7 +526,7 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { bool handled = false; - if ( widget->isType( UI_TYPE_HTML_WIDGET ) && widget->asType()->isInline() ) { + if ( widget->isType( UI_TYPE_TEXTSPAN ) && widget->asType()->isInline() ) { UITextSpan* textSpan = widget->asType(); Int64 startChar = curCharIdx; Int64 endChar = curCharIdx; @@ -624,7 +624,9 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { Rectf atomicBounds( maxF, maxF, lowF, lowF ); Rectf formattingMargin; if ( getAtomicWidgetFragmentBounds( widget, atomicBounds, &formattingMargin ) ) { - Rectf margin = UIHTMLWidget::getFormattingContextLayoutPixelsMargin( widget ); + Rectf margin = widget->isType( UI_TYPE_HTML_WIDGET ) + ? widget->asType()->resolveUsedMargins().value + : widget->getLayoutPixelsMargin(); Vector2f targetPos( atomicBounds.Left + margin.Left, atomicBounds.Top + formattingMargin.Top ); @@ -660,7 +662,9 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { size_t lineIdx = currentSpan > 0 ? currentLine : currentLine - 1; Float lineY = lines[lineIdx].y; - Rectf margin = UIHTMLWidget::getFormattingContextLayoutPixelsMargin( widget ); + Rectf margin = widget->isType( UI_TYPE_HTML_WIDGET ) + ? widget->asType()->resolveUsedMargins().value + : widget->getLayoutPixelsMargin(); Vector2f targetPos( contentOffset.Left + span->position.x + margin.Left, contentOffset.Top + lineY + span->position.y + margin.Top ); diff --git a/src/eepp/ui/gridlayouter.cpp b/src/eepp/ui/gridlayouter.cpp index aa064b864..b211106ca 100644 --- a/src/eepp/ui/gridlayouter.cpp +++ b/src/eepp/ui/gridlayouter.cpp @@ -939,34 +939,66 @@ void GridLayouter::applyLayout() { if ( as == CSSAlignSelf::Auto ) as = CSSAlignSelf::Stretch; - // Apply alignment - Float finalX = cellX, finalY = cellY, finalW = cellW, finalH = cellH; - if ( js == CSSJustifySelf::Stretch ) { - finalW = cellW; - finalX = cellX; + // CSS Grid auto margins absorb the grid area's remaining space before self-alignment. + // Keep the computed margins immutable; these are used values for this placement only. + Rectf margin = item.widget->getLayoutPixelsMargin(); + const bool autoLeft = item.widget->hasLayoutMarginLeftAuto(); + const bool autoRight = item.widget->hasLayoutMarginRightAuto(); + const bool autoTop = item.widget->hasLayoutMarginTopAuto(); + const bool autoBottom = item.widget->hasLayoutMarginBottomAuto(); + if ( autoLeft ) + margin.Left = 0.f; + if ( autoRight ) + margin.Right = 0.f; + if ( autoTop ) + margin.Top = 0.f; + if ( autoBottom ) + margin.Bottom = 0.f; + + Float areaW = eemax( 0.f, cellW - margin.Left - margin.Right ); + Float areaH = eemax( 0.f, cellH - margin.Top - margin.Bottom ); + Float finalX = cellX + margin.Left; + Float finalY = cellY + margin.Top; + Float finalW = areaW; + Float finalH = areaH; + if ( autoLeft || autoRight ) { + finalW = item.widget->getPixelsSize().getWidth(); + Float free = eemax( 0.f, areaW - finalW ); + if ( autoLeft && autoRight ) + finalX += free * 0.5f; + else if ( autoLeft ) + finalX += free; + } else if ( js == CSSJustifySelf::Stretch ) { + finalW = areaW; } else if ( js == CSSJustifySelf::Center ) { Float iw = item.widget->getPixelsSize().getWidth(); finalW = iw; - finalX = cellX + ( cellW - iw ) * 0.5f; + finalX += ( areaW - iw ) * 0.5f; } else if ( js == CSSJustifySelf::End || js == CSSJustifySelf::FlexEnd ) { Float iw = item.widget->getPixelsSize().getWidth(); finalW = iw; - finalX = cellX + cellW - iw; + finalX += areaW - iw; } else { finalW = item.widget->getPixelsSize().getWidth(); } - if ( as == CSSAlignSelf::Stretch ) { - finalH = cellH; - finalY = cellY; + if ( autoTop || autoBottom ) { + finalH = item.widget->getPixelsSize().getHeight(); + Float free = eemax( 0.f, areaH - finalH ); + if ( autoTop && autoBottom ) + finalY += free * 0.5f; + else if ( autoTop ) + finalY += free; + } else if ( as == CSSAlignSelf::Stretch ) { + finalH = areaH; } else if ( as == CSSAlignSelf::Center ) { Float ih = item.widget->getPixelsSize().getHeight(); finalH = ih; - finalY = cellY + ( cellH - ih ) * 0.5f; + finalY += ( areaH - ih ) * 0.5f; } else if ( as == CSSAlignSelf::FlexEnd ) { Float ih = item.widget->getPixelsSize().getHeight(); finalH = ih; - finalY = cellY + cellH - ih; + finalY += areaH - ih; } else { finalH = item.widget->getPixelsSize().getHeight(); } diff --git a/src/eepp/ui/uihtmlimage.cpp b/src/eepp/ui/uihtmlimage.cpp index 2230a14c2..ef2d29e97 100644 --- a/src/eepp/ui/uihtmlimage.cpp +++ b/src/eepp/ui/uihtmlimage.cpp @@ -1,87 +1,505 @@ #include - #define PUGIXML_HEADER_ONLY -#include - #include -#include +#include #include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include + +#include namespace EE { namespace UI { +namespace { + +std::string getTextureCacheName( const Network::URI& uri ) { + std::string filePath( uri.toString() ); + if ( String::startsWith( filePath, "file://" ) ) + filePath = filePath.substr( 7 ); + else + filePath = uri.getFSPath(); +#if EE_PLATFORM == EE_PLATFORM_WIN + if ( filePath.size() >= 3 && filePath[0] == '/' && String::isLetter( filePath[1] ) && + filePath[2] == ':' ) + filePath = filePath.substr( 1 ); +#endif + FileSystem::filePathRemoveProcessPath( filePath ); + return filePath; +} + +TexturePtr loadFileTextureCached( const ResourceScopePtr& scope, const std::string& filePath, + const std::string& cacheName ) { + static std::mutex loadMutex; + std::lock_guard lock( loadMutex ); + if ( TexturePtr texture = scope->findTexture( cacheName ) ) + return texture; + TexturePtr texture = TextureFactory::instance()->loadFromFile( + filePath, false, Texture::ClampMode::ClampToEdge, false, false ); + if ( texture ) + scope->publishLocal( cacheName, texture ); + return texture; +} + +} // namespace + UIHTMLImage* UIHTMLImage::New() { return eeNew( UIHTMLImage, () ); } -UIHTMLImage::UIHTMLImage() : UIImage( "img" ) { +UIHTMLImage::UIHTMLImage() : UIHTMLWidget( "img" ) { mFlags |= UI_HTML_ELEMENT; - mWidthPolicy = SizePolicy::WrapContent; - mHeightPolicy = SizePolicy::WrapContent; - mScaleType = UIScaleType::Expand; + mWidthPolicy = mHeightPolicy = SizePolicy::WrapContent; + setDisplay( CSSDisplay::Inline ); } -UIHTMLImage::~UIHTMLImage() {} +UIHTMLImage::~UIHTMLImage() { + if ( mAsyncImageAlive ) + mAsyncImageAlive->store( false, std::memory_order_release ); + clearDrawable(); +} Uint32 UIHTMLImage::getType() const { return UI_TYPE_HTML_IMAGE; } bool UIHTMLImage::isType( const Uint32& type ) const { - return UIHTMLImage::getType() == type ? true : UIImage::isType( type ); + return type == getType() || UIHTMLWidget::isType( type ); } void UIHTMLImage::loadFromXmlNode( const pugi::xml_node& node ) { - for ( auto& attr : node.attributes() ) { + for ( auto& attr : node.attributes() ) if ( String::iequals( attr.name(), "alt" ) ) { mAlt = attr.value(); break; } - } - - UIImage::loadFromXmlNode( node ); + UIHTMLWidget::loadFromXmlNode( node ); } void UIHTMLImage::draw() { - if ( mVisible && NULL != mDrawable && 0.f != mAlpha ) { - UIImage::draw(); - } else if ( mVisible && 0.f != mAlpha && !mAlt.empty() ) { - UINode::draw(); - - auto* themeManager = getUISceneNode()->getUIThemeManager(); - FontStyleConfig fontStyleConfig; - fontStyleConfig.Font = themeManager->getDefaultFont(); - fontStyleConfig.CharacterSize = themeManager->getDefaultFontSize(); - - Color fontColor = Color::White; - Node* parent = mParentNode; - while ( parent ) { - if ( parent->isWidget() ) { - auto* w = parent->asType(); - if ( w->isType( UI_TYPE_RICHTEXT ) ) { - fontColor = static_cast( w )->getFontColor(); - break; - } - } - parent = parent->getParent(); + if ( mVisible && getDrawable() && mAlpha != 0.f ) { + UIHTMLWidget::draw(); + calcDestSize(); + mDrawable->setColor( mColor ); + mDrawable->draw( { std::trunc( mScreenPos.x ) + std::trunc( mAlignOffset.x ), + std::trunc( mScreenPos.y ) + std::trunc( mAlignOffset.y ) }, + mDestSize ); + mDrawable->clearColor(); + return; + } + if ( !mVisible || mAlpha == 0.f || mAlt.empty() ) + return; + UINode::draw(); + auto* theme = getUISceneNode()->getUIThemeManager(); + FontStyleConfig style; + style.Font = theme->getDefaultFont(); + style.CharacterSize = theme->getDefaultFontSize(); + Color color = Color::White; + for ( Node* parent = mParentNode; parent; parent = parent->getParent() ) + if ( parent->isType( UI_TYPE_RICHTEXT ) ) { + color = parent->asType()->getFontColor(); + break; } - fontStyleConfig.FontColor = Color( fontColor.r, fontColor.g, fontColor.b, mAlpha ); + style.FontColor = { color.r, color.g, color.b, static_cast( mAlpha ) }; + Float width = Text::getTextWidth( mAlt, style ); + Float available = mSize.x - mPaddingPx.Left - mPaddingPx.Right; + Float x = mScreenPos.x + mPaddingPx.Left + eemax( 0.f, ( available - width ) * 0.5f ); + Float y = mScreenPos.y + mPaddingPx.Top + + ( mSize.y - mPaddingPx.Top - mPaddingPx.Bottom - + PixelDensity::getPixelDensity() * style.CharacterSize ) * + 0.5f; + Text::draw( String( mAlt ), { x, y }, style ); +} - Float textWidth = Text::getTextWidth( mAlt, fontStyleConfig ); - Float availableWidth = mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right; - Float x = mScreenPos.x + mPaddingPx.Left; - Float y = mScreenPos.y + mPaddingPx.Top; +void UIHTMLImage::setAlpha( const Float& alpha ) { + UINode::setAlpha( alpha ); + mColor.a = static_cast( alpha ); +} - if ( textWidth < availableWidth ) - x += ( availableWidth - textWidth ) / 2; +const DrawablePtr& UIHTMLImage::getDrawable() const { + return mDrawable; +} - y += ( mSize.getHeight() - mPaddingPx.Top - mPaddingPx.Bottom - - PixelDensity::getPixelDensity() * fontStyleConfig.CharacterSize ) / - 2; +UIHTMLImage* UIHTMLImage::setDrawable( DrawablePtr drawable ) { + if ( drawable == mDrawable ) + return this; + Sizef oldSize( mSize ); + clearDrawable(); + mDrawable = std::move( drawable ); + sendCommonEvent( Event::OnResourceChange ); + if ( mDrawable ) { + if ( mDrawable->getDrawableType() == Drawable::SPRITE ) { + if ( !isSubscribedForScheduledUpdate() ) + subscribeScheduledUpdate(); + mSpriteChangeCb = + static_cast( mDrawable.get() ) + ->pushEventsCallback( [this]( auto, auto, auto ) { invalidateDraw(); } ); + } else { + if ( mDrawable->isDrawableResource() ) + mResourceChangeConnection = + static_cast( mDrawable.get() ) + ->connectResourceChange( + [this]( DrawableResource& ) { onDrawableResourceChange(); } ); + if ( isSubscribedForScheduledUpdate() ) + unsubscribeScheduledUpdate(); + } + } + autoSizeImage(); + if ( mSize != oldSize ) + notifyLayoutAttrChangeParent( LayoutInvalidation::ParentReplacedFormatting ); + calcDestSize(); + invalidateIntrinsicSize(); + invalidateDraw(); + return this; +} - Text::draw( String( mAlt ), Vector2f( x, y ), fontStyleConfig ); +UIHTMLImage* UIHTMLImage::setDrawable( TexturePtr texture ) { + return setDrawable( texture ? TextureDrawable::New( std::move( texture ) ) : DrawablePtr{} ); +} + +const Color& UIHTMLImage::getColor() const { + return mColor; +} + +UIHTMLImage* UIHTMLImage::setColor( const Color& color ) { + if ( mColor != color ) { + mColor = color; + UINode::setAlpha( color.a ); + invalidateDraw(); + } + return this; +} + +const Vector2f& UIHTMLImage::getAlignOffset() const { + return mAlignOffset; +} + +bool UIHTMLImage::applyProperty( const StyleSheetProperty& property ) { + if ( !checkPropertyDefinition( property ) ) + return false; + switch ( property.getPropertyDefinition()->getPropertyId() ) { + case PropertyId::Src: { + if ( property.getValue().empty() ) + return true; + std::string path( property.getValue() ); + URI uri( path ); + UISceneNode* scene = getUISceneNode(); + if ( scene && uri.getScheme().empty() && !scene->getURI().empty() ) { + uri = scene->solveRelativePath( uri ); + path = uri.toString(); + } + if ( uri.getScheme() == "http" || uri.getScheme() == "https" ) { + loadRemoteDrawable( uri ); + break; + } + if ( mDeferLoad && uri.getScheme() == "file" && loadFileDrawable( uri ) ) + break; + DrawablePtr drawable = + StyleSheetSpecification::instance()->getDrawableImageParser().createDrawable( + path, mSize, this ); + if ( drawable ) + setDrawable( std::move( drawable ) ); + else if ( scene ) + setDrawable( scene->getDrawableResolver().resolve( path ) ); + else { + DrawableResolver resolver( defaultResourceScope() ); + setDrawable( resolver.resolve( path ) ); + } + break; + } + case PropertyId::ScaleType: { + const auto& value = property.getValue(); + if ( String::iequals( value, "expand" ) ) + setScaleType( UIScaleType::Expand ); + else if ( String::iequals( value, "fit-inside" ) || + String::iequals( value, "fit_inside" ) || + String::iequals( value, "fitinside" ) ) + setScaleType( UIScaleType::FitInside ); + else if ( String::iequals( value, "none" ) ) + setScaleType( UIScaleType::None ); + break; + } + case PropertyId::Tint: + setColor( property.asColor() ); + break; + case PropertyId::Defer: + mDeferLoad = property.getValue().empty() || property.asBool(); + break; + default: + return UIHTMLWidget::applyProperty( property ); + } + return true; +} + +Float UIHTMLImage::getMinIntrinsicWidth() const { + if ( mWidthPolicy == SizePolicy::Fixed ) + return getPropertyWidth(); + return ( getDrawable() ? getDrawable()->getMinIntrinsicWidth() * PixelDensity::getPixelDensity() + : 0.f ) + + mPaddingPx.Left + mPaddingPx.Right; +} + +Float UIHTMLImage::getMaxIntrinsicWidth() const { + if ( mWidthPolicy == SizePolicy::Fixed ) + return getPropertyWidth(); + return ( getDrawable() ? getDrawable()->getMaxIntrinsicWidth() * PixelDensity::getPixelDensity() + : 0.f ) + + mPaddingPx.Left + mPaddingPx.Right; +} + +std::string UIHTMLImage::getPropertyString( const PropertyDefinition* property, + const Uint32& index ) const { + if ( !property ) + return ""; + if ( property->getPropertyId() == PropertyId::ScaleType ) + return getScaleType() == UIScaleType::FitInside + ? "fit-inside" + : ( getScaleType() == UIScaleType::Expand ? "expand" : "none" ); + if ( property->getPropertyId() == PropertyId::Tint ) + return getColor().toHexString(); + return UIHTMLWidget::getPropertyString( property, index ); +} + +void UIHTMLImage::scheduledUpdate( const Time& time ) { + if ( mDrawable && mDrawable->getDrawableType() == Drawable::SPRITE ) + static_cast( mDrawable.get() )->update( time ); +} + +void UIHTMLImage::updateLayout() { + autoSizeImage(); + UIHTMLWidget::updateLayout(); +} + +std::vector UIHTMLImage::getPropertiesImplemented() const { + auto properties = UIHTMLWidget::getPropertiesImplemented(); + properties.insert( properties.end(), { PropertyId::ScaleType, PropertyId::Tint } ); + return properties; +} + +const UIScaleType& UIHTMLImage::getScaleType() const { + return mScaleType; +} + +UIHTMLImage* UIHTMLImage::setScaleType( const UIScaleType& type ) { + if ( mScaleType != type ) { + mScaleType = type; + calcDestSize(); + invalidateDraw(); + } + return this; +} + +void UIHTMLImage::autoSizeImage() { + Sizef drawableSize = + mDrawable ? mDrawable->getPixelsSize() * PixelDensity::getPixelDensity() : Sizef::Zero; + if ( drawableSize.x <= 0.f || drawableSize.y <= 0.f ) + return; + Sizef size = getPixelsSize(); + if ( mWidthPolicy == SizePolicy::WrapContent && mHeightPolicy == SizePolicy::WrapContent ) { + size = { drawableSize.x + mPaddingPx.Left + mPaddingPx.Right, + drawableSize.y + mPaddingPx.Top + mPaddingPx.Bottom }; + if ( !mMaxWidthEq.empty() ) { + Float maxWidth = + lengthFromValue( mMaxWidthEq, CSS::PropertyRelativeTarget::ContainingBlockWidth ); + if ( maxWidth > 0.f && size.x > maxWidth ) { + Float scale = ( maxWidth - mPaddingPx.Left - mPaddingPx.Right ) / drawableSize.x; + size = { maxWidth, drawableSize.y * scale + mPaddingPx.Top + mPaddingPx.Bottom }; + } + } + if ( !mMaxHeightEq.empty() ) { + Float maxHeight = + lengthFromValue( mMaxHeightEq, CSS::PropertyRelativeTarget::ContainingBlockHeight ); + if ( maxHeight > 0.f && size.y > maxHeight ) { + Float scale = ( maxHeight - mPaddingPx.Top - mPaddingPx.Bottom ) / drawableSize.y; + size = { drawableSize.x * scale + mPaddingPx.Left + mPaddingPx.Right, maxHeight }; + } + } + } else if ( mWidthPolicy == SizePolicy::WrapContent ) { + Float contentHeight = eemax( 0.f, size.y - mPaddingPx.Top - mPaddingPx.Bottom ); + size.x = + contentHeight * drawableSize.x / drawableSize.y + mPaddingPx.Left + mPaddingPx.Right; + if ( !mMaxWidthEq.empty() ) { + Float maxWidth = + lengthFromValue( mMaxWidthEq, CSS::PropertyRelativeTarget::ContainingBlockWidth ); + if ( maxWidth > 0.f ) + size.x = eemin( size.x, maxWidth ); + } + } else if ( mHeightPolicy == SizePolicy::WrapContent ) { + Float contentWidth = eemax( 0.f, size.x - mPaddingPx.Left - mPaddingPx.Right ); + if ( !mMaxWidthEq.empty() ) { + Float maxWidth = + lengthFromValue( mMaxWidthEq, CSS::PropertyRelativeTarget::ContainingBlockWidth ); + Float maxContent = eemax( 0.f, maxWidth - mPaddingPx.Left - mPaddingPx.Right ); + if ( maxWidth > 0.f && contentWidth > maxContent ) { + contentWidth = maxContent; + size.x = maxWidth; + } + } + size.y = + contentWidth * drawableSize.y / drawableSize.x + mPaddingPx.Top + mPaddingPx.Bottom; + if ( !mMaxHeightEq.empty() ) { + Float maxHeight = + lengthFromValue( mMaxHeightEq, CSS::PropertyRelativeTarget::ContainingBlockHeight ); + if ( maxHeight > 0.f ) + size.y = eemin( size.y, maxHeight ); + } + } + if ( mSize != size ) + setInternalPixelsSize( size.floor() ); +} + +void UIHTMLImage::onSizeChange() { + autoSizeImage(); + calcDestSize(); + UIHTMLWidget::onSizeChange(); +} + +void UIHTMLImage::onSizePolicyChange() { + autoSizeImage(); + UIHTMLWidget::onSizePolicyChange(); +} + +void UIHTMLImage::onAlignChange() { + UIHTMLWidget::onAlignChange(); + calcDestSize(); +} + +void UIHTMLImage::onParentSizeChange( const Vector2f& change ) { + UIHTMLWidget::onParentSizeChange( change ); + autoSizeImage(); +} + +void UIHTMLImage::onDisplayChange() { + UIHTMLWidget::onDisplayChange(); + // CSS 2.2 §10.3.4: a block-level replaced element with width:auto uses its intrinsic width; + // unlike an ordinary block box, it does not fill the containing block. max-width is applied + // after decoding by autoSizeImage(). + const auto* width = getUIStyle() ? getUIStyle()->getProperty( PropertyId::Width ) : nullptr; + if ( getLayoutWidthPolicy() == SizePolicy::MatchParent && + ( width == nullptr || width->value() == "auto" ) ) + setLayoutWidthPolicy( SizePolicy::WrapContent ); +} + +void UIHTMLImage::calcDestSize() { + if ( mScaleType == UIScaleType::Expand ) { + mDestSize = { mSize.x - mPaddingPx.Left - mPaddingPx.Right, + mSize.y - mPaddingPx.Top - mPaddingPx.Bottom }; + } else if ( mScaleType == UIScaleType::FitInside && mDrawable ) { + Sizef pixels( mDrawable->getPixelsSize() ); + Float scale = eemin( ( mSize.x - mPaddingPx.Left - mPaddingPx.Right ) / pixels.x, + ( mSize.y - mPaddingPx.Top - mPaddingPx.Bottom ) / pixels.y ); + mDestSize = scale < 1.f ? pixels * scale : pixels; + } else if ( mDrawable ) { + mDestSize = mDrawable->getPixelsSize(); + } + mDestSize = mDestSize.floor(); + mAlignOffset = { mPaddingPx.Left, mPaddingPx.Top }; +} + +void UIHTMLImage::clearDrawable() { + if ( mDrawable && mDrawable->getDrawableType() == Drawable::SPRITE ) { + static_cast( mDrawable.get() )->popEventsCallback( mSpriteChangeCb ); + mSpriteChangeCb = 0; + } + mResourceChangeConnection.disconnect(); + mDrawable.reset(); +} + +void UIHTMLImage::onDrawableResourceChange() { + runOnMainThread( [this] { + Sizef oldSize( mSize ); + autoSizeImage(); + calcDestSize(); + if ( mSize != oldSize ) + notifyLayoutAttrChangeParent( LayoutInvalidation::ParentReplacedFormatting ); + invalidateIntrinsicSize(); + invalidateDraw(); + } ); +} + +bool UIHTMLImage::loadFileDrawable( const Network::URI& uri ) { + UISceneNode* scene = getUISceneNode(); + if ( !scene || !scene->getThreadPool() || + !Window::Engine::instance()->isSharedGLContextEnabled() ) + return false; + Uint64 loadId = ++mRemoteImageLoadId; + std::string filePath = uri.getFSPath(); + std::string cacheName = getTextureCacheName( uri ); + ResourceScopePtr resourceScope = scene->getResourceScope(); + if ( TexturePtr texture = resourceScope->findTexture( cacheName ) ) { + setDrawable( std::move( texture ) ); + return true; + } + auto resourceState = scene->getAsyncResourceLoadState(); + Uint64 generation = + resourceState ? resourceState->generation.load( std::memory_order_acquire ) : 0; + if ( !mAsyncImageAlive ) + mAsyncImageAlive = std::make_shared>( true ); + auto alive = mAsyncImageAlive; + scene->getThreadPool()->run( [resourceState, generation, resourceScope, alive, loadId, + filePath = std::move( filePath ), + cacheName = std::move( cacheName ), this] { + if ( !UISceneNode::isAsyncResourceLoadCurrent( resourceState, generation ) || !alive || + !alive->load( std::memory_order_acquire ) ) + return; + TexturePtr texture = loadFileTextureCached( resourceScope, filePath, cacheName ); + if ( !texture ) + return; + UISceneNode::runAsyncResourceOnMainThread( + resourceState, generation, [alive, loadId, texture, this]( UISceneNode* ) mutable { + if ( !alive || !alive->load( std::memory_order_acquire ) || + loadId != mRemoteImageLoadId ) + return; + setDrawable( std::move( texture ) ); + } ); + } ); + return true; +} + +void UIHTMLImage::loadRemoteDrawable( const Network::URI& uri ) { + UISceneNode* scene = getUISceneNode(); + if ( !scene ) + return; + std::string url = uri.toString(); + if ( TexturePtr texture = scene->getResourceScope()->findTexture( url ) ) { + TextureDrawable* current = + mDrawable && mDrawable->getDrawableType() == Drawable::TEXTUREDRAWABLE + ? static_cast( mDrawable.get() ) + : nullptr; + if ( !current || current->getTexture() != texture ) { + ++mRemoteImageLoadId; + setDrawable( std::move( texture ) ); + } + return; + } + WebResourceRequest request; + request.uri = uri; + request.kind = WebResourceKind::Image; + request.proxy = Http::getEnvProxyURI(); + TexturePtr texture = scene->requestWebTexture( + std::move( request ), [url = std::move( url )]( const WebResourceResult& result ) { + if ( !result.success ) + Log::debug( "UIHTMLImage: could not download image: %s. Error: %d\n%s", url, + result.status, result.error ); + } ); + if ( texture ) { + TextureDrawable* current = + mDrawable && mDrawable->getDrawableType() == Drawable::TEXTUREDRAWABLE + ? static_cast( mDrawable.get() ) + : nullptr; + if ( !current || current->getTexture() != texture ) { + ++mRemoteImageLoadId; + setDrawable( std::move( texture ) ); + } } } @@ -97,4 +515,8 @@ UIHTMLImage* UIHTMLImage::setAlt( const std::string& alt ) { return this; } +bool UIHTMLImage::isInline() const { + return getDisplay() == CSSDisplay::Inline && getCSSFloat() == CSSFloat::None && !isOutOfFlow(); +} + }} // namespace EE::UI diff --git a/src/eepp/ui/uihtmlwidget.cpp b/src/eepp/ui/uihtmlwidget.cpp index 74d733001..bbccab54a 100644 --- a/src/eepp/ui/uihtmlwidget.cpp +++ b/src/eepp/ui/uihtmlwidget.cpp @@ -411,41 +411,73 @@ void UIHTMLWidget::setCSSClear( CSSClear cssClear ) { } } -Rectf UIHTMLWidget::getNormalFlowLayoutPixelsMargin() const { - Rectf margin = getLayoutPixelsMargin(); - if ( hasLayoutMarginTopAuto() ) - margin.Top = 0.f; - if ( hasLayoutMarginBottomAuto() ) - margin.Bottom = 0.f; - return margin; +CSSFormattingRole UIHTMLWidget::getFormattingRole() const { + if ( mPosition == CSSPosition::Absolute ) + return CSSFormattingRole::Absolute; + if ( mPosition == CSSPosition::Fixed ) + return CSSFormattingRole::Fixed; + Node* parent = getParent(); + if ( parent && parent->isType( UI_TYPE_HTML_WIDGET ) ) { + auto* htmlParent = parent->asType(); + if ( htmlParent->isFlex() ) + return CSSFormattingRole::FlexItem; + if ( htmlParent->isGrid() ) + return CSSFormattingRole::GridItem; + } + if ( mFloat != CSSFloat::None ) + return CSSFormattingRole::Float; + if ( mDisplay == CSSDisplay::Inline ) + return CSSFormattingRole::Inline; + if ( mDisplay == CSSDisplay::InlineBlock || mDisplay == CSSDisplay::InlineFlex || + mDisplay == CSSDisplay::InlineGrid ) + return CSSFormattingRole::InlineBlock; + if ( mDisplay == CSSDisplay::Table ) + return CSSFormattingRole::Table; + return CSSFormattingRole::NormalFlowBlock; } -Rectf UIHTMLWidget::getFormattingContextLayoutPixelsMargin( UIWidget* widget ) { - bool resolveHorizontalAutoMargins = widget->getLayoutWidthPolicy() == SizePolicy::MatchParent; - if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) { - auto* htmlWidget = widget->asType(); - resolveHorizontalAutoMargins = !htmlWidget->isOutOfFlow() && - htmlWidget->getCSSFloat() == CSSFloat::None && - !widget->isInlineDisplay(); - } +CSSUsedMargins UIHTMLWidget::resolveUsedMargins() const { + CSSUsedMargins used{ getLayoutPixelsMargin(), 0 }; + if ( hasLayoutMarginLeftAuto() ) + used.autoSides |= MarginAuto::Left; + if ( hasLayoutMarginRightAuto() ) + used.autoSides |= MarginAuto::Right; + if ( hasLayoutMarginTopAuto() ) + used.autoSides |= MarginAuto::Top; + if ( hasLayoutMarginBottomAuto() ) + used.autoSides |= MarginAuto::Bottom; - if ( resolveHorizontalAutoMargins && widget->hasLayoutMarginAuto() ) - widget->updateLayoutMarginAuto(); + if ( used.autoSides & MarginAuto::Left ) + used.value.Left = 0.f; + if ( used.autoSides & MarginAuto::Right ) + used.value.Right = 0.f; + if ( used.autoSides & MarginAuto::Top ) + used.value.Top = 0.f; + if ( used.autoSides & MarginAuto::Bottom ) + used.value.Bottom = 0.f; - Rectf margin = widget->isType( UI_TYPE_HTML_WIDGET ) - ? widget->asType()->getNormalFlowLayoutPixelsMargin() - : widget->getLayoutPixelsMargin(); - if ( !resolveHorizontalAutoMargins ) { - if ( widget->hasLayoutMarginLeftAuto() ) - margin.Left = 0.f; - if ( widget->hasLayoutMarginRightAuto() ) - margin.Right = 0.f; - if ( widget->hasLayoutMarginTopAuto() ) - margin.Top = 0.f; - if ( widget->hasLayoutMarginBottomAuto() ) - margin.Bottom = 0.f; + if ( getFormattingRole() != CSSFormattingRole::NormalFlowBlock || + !( used.autoSides & ( MarginAuto::Left | MarginAuto::Right ) ) ) + return used; + + Node* parent = getParent(); + if ( !parent || !parent->isWidget() ) + return used; + const UIWidget* containingBlock = parent->asType(); + const Rectf contentOffset = containingBlock->getPixelsContentOffset(); + const Float available = + eemax( 0.f, containingBlock->getPixelsSize().getWidth() - contentOffset.Left - + contentOffset.Right - getPixelsSize().getWidth() - used.value.Left - + used.value.Right ); + if ( ( used.autoSides & MarginAuto::Left ) && ( used.autoSides & MarginAuto::Right ) ) { + used.value.Left = available * 0.5f; + used.value.Right = available - used.value.Left; + } else if ( used.autoSides & MarginAuto::Left ) { + used.value.Left = available; + } else { + used.value.Right = available; } - return margin; + return used; } void UIHTMLWidget::setBaselineAlign( const CSSBaselineAlignValue& baselineAlign ) { @@ -1251,11 +1283,19 @@ void UIHTMLWidget::updateOutOfFlowPosition() { if ( !cb ) return; - Rectf cbContentOffset = cb->getPixelsContentOffset(); + // CSS Positioned Layout: a non-inline positioned ancestor establishes the containing block + // from its padding box. Insets therefore start at the padding edge, not the content edge. + // getPixelsContentOffset() includes both border and padding, so subtract padding to recover the + // padding-box origin and exclude only borders from its dimensions. + const Rectf cbContentOffset = cb->getPixelsContentOffset(); + const Rectf cbPadding = cb->getPixelsPadding(); + const Rectf cbPaddingBoxOffset{ + cbContentOffset.Left - cbPadding.Left, cbContentOffset.Top - cbPadding.Top, + cbContentOffset.Right - cbPadding.Right, cbContentOffset.Bottom - cbPadding.Bottom }; Float cbContentWidth = - cb->getPixelsSize().getWidth() - cbContentOffset.Left - cbContentOffset.Right; + cb->getPixelsSize().getWidth() - cbPaddingBoxOffset.Left - cbPaddingBoxOffset.Right; Float cbContentHeight = - cb->getPixelsSize().getHeight() - cbContentOffset.Top - cbContentOffset.Bottom; + cb->getPixelsSize().getHeight() - cbPaddingBoxOffset.Top - cbPaddingBoxOffset.Bottom; Rectf margin = getLayoutPixelsMargin(); Float childWidth = getPixelsSize().getWidth(); @@ -1325,6 +1365,38 @@ void UIHTMLWidget::updateOutOfFlowPosition() { bottom = lengthFromValue( mBottomEq, CSS::PropertyRelativeTarget::ContainingBlockHeight, 0 ); + // CSS 2.2 §10.3.7/§10.6.4: when both insets and the size are definite, auto margins + // absorb the remaining space in the positioned constraint equation. Keep this pass-local; + // the same box may later participate under different insets or a different containing block. + auto solvePositionedAutoMargins = []( Float containingSize, Float startInset, Float endInset, + Float boxSize, Float& startMargin, Float& endMargin, + bool startAuto, bool endAuto ) { + if ( !startAuto && !endAuto ) + return; + if ( startAuto ) + startMargin = 0.f; + if ( endAuto ) + endMargin = 0.f; + Float free = eemax( 0.f, containingSize - startInset - endInset - boxSize - startMargin - + endMargin ); + if ( startAuto && endAuto ) { + startMargin = free * 0.5f; + endMargin = free - startMargin; + } else if ( startAuto ) { + startMargin = free; + } else { + endMargin = free; + } + }; + if ( useLeft && useRight && getLayoutWidthPolicy() == SizePolicy::Fixed ) + solvePositionedAutoMargins( cbContentWidth, left, right, childWidth, margin.Left, + margin.Right, hasLayoutMarginLeftAuto(), + hasLayoutMarginRightAuto() ); + if ( useTop && useBottom && getLayoutHeightPolicy() == SizePolicy::Fixed ) + solvePositionedAutoMargins( cbContentHeight, top, bottom, childHeight, margin.Top, + margin.Bottom, hasLayoutMarginTopAuto(), + hasLayoutMarginBottomAuto() ); + Float finalWidth = childWidth; Float finalHeight = childHeight; @@ -1355,7 +1427,7 @@ void UIHTMLWidget::updateOutOfFlowPosition() { top += margin.Top; left += margin.Left; - Vector2f cbPos( cbContentOffset.Left, cbContentOffset.Top ); + Vector2f cbPos( cbPaddingBoxOffset.Left, cbPaddingBoxOffset.Top ); cbPos.x += left; cbPos.y += top; diff --git a/src/eepp/ui/uiimage.cpp b/src/eepp/ui/uiimage.cpp index d4ac20668..f770c3ff6 100644 --- a/src/eepp/ui/uiimage.cpp +++ b/src/eepp/ui/uiimage.cpp @@ -138,18 +138,11 @@ void UIImage::onAutoSize() { if ( nullptr == mDrawable ) return; - Sizef drawableSize = mFlags & UI_HTML_ELEMENT - ? mDrawable->getPixelsSize() * PixelDensity::getPixelDensity() - : mDrawable->getPixelsSize(); + Sizef drawableSize = mDrawable->getPixelsSize(); if ( drawableSize.getWidth() <= 0 || drawableSize.getHeight() <= 0 ) return; Sizef size( getPixelsSize() ); - if ( mFlags & UI_HTML_ELEMENT ) { - size.x = eemax( size.x, getPropertyLength( PropertyId::LayoutWidth ) ); - size.y = eemax( size.y, getPropertyLength( PropertyId::LayoutHeight ) ); - } - if ( ( mFlags & UI_AUTO_SIZE ) && Sizef::Zero == getPixelsSize() ) size = drawableSize.asInt().asFloat(); diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index b89e2e7cd..4ecf6a7a8 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -1943,7 +1943,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri bool handled = false; - if ( widget->isType( UI_TYPE_HTML_WIDGET ) && widget->asType()->isInline() && + if ( widget->isType( UI_TYPE_TEXTSPAN ) && widget->asType()->isInline() && widget->asType()->getCSSFloat() == CSSFloat::None && !widget->asType()->isOutOfFlow() ) { UITextSpan* span = widget->asType(); @@ -2044,7 +2044,9 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri richText.addLineBreak( true, breakLineHeight ); lastSpanEndsWithSpace = false; } else { - Rectf margin = UIHTMLWidget::getFormattingContextLayoutPixelsMargin( widget ); + Rectf margin = widget->isType( UI_TYPE_HTML_WIDGET ) + ? widget->asType()->resolveUsedMargins().value + : widget->getLayoutPixelsMargin(); bool isBlock = widget->getLayoutWidthPolicy() == SizePolicy::MatchParent; if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) { CSSDisplay display = widget->asType()->getDisplay(); diff --git a/src/eepp/ui/uiwebview.cpp b/src/eepp/ui/uiwebview.cpp index bfcd61cf2..96160f6b7 100644 --- a/src/eepp/ui/uiwebview.cpp +++ b/src/eepp/ui/uiwebview.cpp @@ -125,7 +125,7 @@ static void resetViewportDependentDocumentWidths( UIWidget* container ) { if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) { auto* htmlWidget = widget->asType(); normalFlow = !htmlWidget->isOutOfFlow(); - margin = htmlWidget->getNormalFlowLayoutPixelsMargin(); + margin = htmlWidget->resolveUsedMargins().value; } if ( normalFlow ) { diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index ab6a6950a..b1f4c8009 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -623,6 +623,11 @@ UITooltip* UIWidget::getTooltip() { void UIWidget::calculateAutoMargin() { if ( !mMarginAuto || !getParent() || !getParent()->isWidget() ) return; + // HTML auto margins are computed used values owned by the active formatting context. Mutating + // the stored resolved margin here leaks block-layout results into inline, flex, positioned, or + // asynchronously restyled passes. This native solver remains for eepp layout widgets only. + if ( mFlags & UI_HTML_ELEMENT ) + return; UIWidget* parent = getParent()->asType(); Sizef parentSize = parent->getPixelsSize(); diff --git a/src/tests/unit_tests/uihtml_grid_test.cpp b/src/tests/unit_tests/uihtml_grid_test.cpp index 29ded78a0..930a1f109 100644 --- a/src/tests/unit_tests/uihtml_grid_test.cpp +++ b/src/tests/unit_tests/uihtml_grid_test.cpp @@ -1996,3 +1996,29 @@ UTEST( GridContainer, gradientFixtureAutoFitItemsStayInsideOnResize ) { Engine::destroySingleton(); PixelDensity::setPixelDensity( 1.f ); } + +UTEST( GridContainer, autoMarginsAbsorbGridAreaFreeSpace ) { + Engine::instance()->createWindow( WindowSettings( 640, 480, "Grid Auto Margins", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + init_grid_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+
+
+ + )html" ) ); + sceneNode->update( Seconds( 1 ) ); + sceneNode->updateDirtyLayouts(); + auto* item = sceneNode->getRoot()->find( "item" )->asType(); + ASSERT_TRUE( item != nullptr ); + EXPECT_NEAR( item->getPixelsPosition().x, 100.f, 0.5f ); + EXPECT_NEAR( item->getPixelsPosition().y, 80.f, 0.5f ); + EXPECT_NEAR( item->getPixelsSize().x, 100.f, 0.5f ); + EXPECT_NEAR( item->getPixelsSize().y, 40.f, 0.5f ); + Engine::destroySingleton(); +} diff --git a/src/tests/unit_tests/uihtml_position_tests.cpp b/src/tests/unit_tests/uihtml_position_tests.cpp index 3630f7d84..0919a2c96 100644 --- a/src/tests/unit_tests/uihtml_position_tests.cpp +++ b/src/tests/unit_tests/uihtml_position_tests.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -89,6 +90,12 @@ UTEST( UIHTMLWidget, positionOutOfFlow_AbsoluteRelToRelative ) { absoluteChild->setOffsets( Rectf( 25, 15, 0, 0 ) ); // L, T, R, B absoluteChild->setPixelsSize( 50, 50 ); + UIHTMLImage* absoluteImage = UIHTMLImage::New(); + absoluteImage->setParent( staticChild ); + absoluteImage->setCSSPosition( CSSPosition::Absolute ); + absoluteImage->setOffsets( Rectf( 25, 15, 0, 0 ) ); + absoluteImage->setPixelsSize( 40, 40 ); + sceneNode->updateDirtyLayouts(); UIWidget* cb = absoluteChild->getContainingBlock(); @@ -96,13 +103,56 @@ UTEST( UIHTMLWidget, positionOutOfFlow_AbsoluteRelToRelative ) { Vector2f worldPos = absoluteChild->convertToWorldSpace( { 0, 0 } ); // rootContainer world pos is 100, 100 - // cb padding left = 10, top = 20 + // The containing block is the relative ancestor's padding box. left/top start at its padding + // edge (inside any border), not at its content edge, so the ancestor's padding is not added. // absoluteChild offset left = 25, top = 15 - // worldPos should be 100 + 10 + 25 = 135 - // worldPos y should be 100 + 20 + 15 = 135 - EXPECT_NEAR( 135.f, worldPos.x, 1.f ); - EXPECT_NEAR( 135.f, worldPos.y, 1.f ); + EXPECT_NEAR( 125.f, worldPos.x, 1.f ); + EXPECT_NEAR( 115.f, worldPos.y, 1.f ); + EXPECT_EQ( absoluteImage->getContainingBlock(), rootContainer ); + Vector2f imageWorldPos = absoluteImage->convertToWorldSpace( { 0, 0 } ); + EXPECT_NEAR( 125.f, imageWorldPos.x, 1.f ); + EXPECT_NEAR( 115.f, imageWorldPos.y, 1.f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLWidget, positionOutOfFlow_DefiniteInsetsDistributeAutoMargins ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + auto* containingBlock = UIHTMLWidget::New(); + containingBlock->setParent( sceneNode->getRoot() ); + containingBlock->setCSSPosition( CSSPosition::Relative ); + containingBlock->setPixelsSize( 400.f, 200.f ); + containingBlock->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + + auto configure = [containingBlock]( UIHTMLWidget* child ) { + child->setParent( containingBlock ); + child->applyProperty( StyleSheetProperty( "position", "absolute" ) ); + child->applyProperty( StyleSheetProperty( "left", "20px" ) ); + child->applyProperty( StyleSheetProperty( "right", "20px" ) ); + child->applyProperty( StyleSheetProperty( "top", "10px" ) ); + child->applyProperty( StyleSheetProperty( "bottom", "10px" ) ); + child->applyProperty( StyleSheetProperty( "width", "100px" ) ); + child->applyProperty( StyleSheetProperty( "height", "40px" ) ); + child->applyProperty( StyleSheetProperty( "margin-left", "auto" ) ); + child->applyProperty( StyleSheetProperty( "margin-right", "auto" ) ); + child->applyProperty( StyleSheetProperty( "margin-top", "auto" ) ); + child->applyProperty( StyleSheetProperty( "margin-bottom", "auto" ) ); + }; + auto* box = UIHTMLWidget::New(); + configure( box ); + auto* image = UIHTMLImage::New(); + configure( image ); + + sceneNode->updateDirtyLayouts(); + containingBlock->positionOutOfFlowChildren(); + EXPECT_NEAR( box->getPixelsPosition().x, 150.f, 0.5f ); + EXPECT_NEAR( box->getPixelsPosition().y, 80.f, 0.5f ); + EXPECT_NEAR( image->getPixelsPosition().x, 150.f, 0.5f ); + EXPECT_NEAR( image->getPixelsPosition().y, 80.f, 0.5f ); + EXPECT_TRUE( box->getLayoutPixelsMargin() == Rectf::Zero ); + EXPECT_TRUE( image->getLayoutPixelsMargin() == Rectf::Zero ); Engine::destroySingleton(); } diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index 4976a9d90..518c50508 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -5092,6 +5092,56 @@ UTEST( UIHTML, WebViewAsyncInlineImageAutoMarginsResolveToZero ) { Engine::destroySingleton(); } +UTEST( UIHTML, FormattingRoleUsedMarginsAreNonMutating ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 640, 480, "CSS used margin roles", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + UISceneNode* sceneNode = init_test_inline_block(); + auto* parent = UIHTMLWidget::New(); + parent->setParent( sceneNode->getRoot() ); + parent->setPixelsSize( 400.f, 200.f ); + parent->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + auto* image = UIHTMLImage::New(); + image->setParent( parent ); + image->setPixelsSize( 100.f, 40.f ); + image->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + image->setLayoutPixelsMargin( { 23.f, 17.f, 29.f, 19.f } ); + image->setLayoutMarginAuto( true, true, true, true ); + + EXPECT_EQ( image->getFormattingRole(), CSSFormattingRole::Inline ); + auto used = image->resolveUsedMargins(); + EXPECT_TRUE( used.value == Rectf::Zero ); + EXPECT_TRUE( image->getLayoutPixelsMargin() == Rectf( 23.f, 17.f, 29.f, 19.f ) ); + + image->setDisplay( CSSDisplay::Block ); + EXPECT_EQ( image->getFormattingRole(), CSSFormattingRole::NormalFlowBlock ); + used = image->resolveUsedMargins(); + EXPECT_NEAR( used.value.Left, 150.f, 0.01f ); + EXPECT_NEAR( used.value.Right, 150.f, 0.01f ); + EXPECT_EQ( used.value.Top, 0.f ); + EXPECT_EQ( used.value.Bottom, 0.f ); + + image->setCSSFloat( CSSFloat::Left ); + EXPECT_EQ( image->getFormattingRole(), CSSFormattingRole::Float ); + EXPECT_TRUE( image->resolveUsedMargins().value == Rectf::Zero ); + image->setCSSFloat( CSSFloat::None ); + image->setCSSPosition( CSSPosition::Absolute ); + EXPECT_EQ( image->getFormattingRole(), CSSFormattingRole::Absolute ); + EXPECT_TRUE( image->resolveUsedMargins().value == Rectf::Zero ); + image->setCSSPosition( CSSPosition::Static ); + + parent->setDisplay( CSSDisplay::Flex ); + EXPECT_EQ( image->getFormattingRole(), CSSFormattingRole::FlexItem ); + EXPECT_TRUE( image->resolveUsedMargins().value == Rectf::Zero ); + parent->setDisplay( CSSDisplay::Grid ); + EXPECT_EQ( image->getFormattingRole(), CSSFormattingRole::GridItem ); + EXPECT_TRUE( image->resolveUsedMargins().value == Rectf::Zero ); + + (void)win; + Engine::destroySingleton(); +} + UTEST( UIHTML, KittyHomeSmallDoesNotHang ) { Engine::instance()->createWindow( WindowSettings( 1024, 768, "Kitty Home Small Test", WindowStyle::Default, WindowBackend::Default, @@ -5898,7 +5948,7 @@ UTEST( UIHTML, RonStonerDeferredImagesUpdateDocumentHeight ) { auto allImagesLoaded = [&images] { for ( auto* image : images ) { - auto* uiImage = image ? image->asType() : nullptr; + auto* uiImage = image ? image->asType() : nullptr; if ( uiImage == nullptr || uiImage->getDrawable() == nullptr ) return false; } @@ -5913,6 +5963,24 @@ UTEST( UIHTML, RonStonerDeferredImagesUpdateDocumentHeight ) { for ( int i = 0; i < 30; ++i ) pump(); + ASSERT_GT( images.size(), 1u ); + UIWidget* champion = images[1]; + UIWidget* championParagraph = champion->getParent()->asType(); + ASSERT_TRUE( championParagraph != nullptr ); + EXPECT_NEAR( champion->getPixelsSize().getWidth(), + championParagraph->getPixelsSize().getWidth(), 1.f ); + EXPECT_GT( champion->getPixelsSize().getHeight(), 500.f ); + EXPECT_GE( championParagraph->getPixelsSize().getHeight(), + champion->getPixelsSize().getHeight() ); + Node* following = championParagraph->getNextNode(); + while ( following && + ( !following->isWidget() || following->asType()->getElementTag() != "p" ) ) + following = following->getNextNode(); + ASSERT_TRUE( following != nullptr ); + EXPECT_GE( following->asType()->getPixelsPosition().y + 1.f, + championParagraph->getPixelsPosition().y + + championParagraph->getPixelsSize().getHeight() ); + const Float bodyHeightAfterAsyncLoad = body->getPixelsSize().getHeight(); const Float docHeightAfterAsyncLoad = webView->getDocumentContainer()->getPixelsSize().getHeight(); diff --git a/src/tests/unit_tests/uiwebview_tests.cpp b/src/tests/unit_tests/uiwebview_tests.cpp index 9da438a15..ff60d2a1a 100644 --- a/src/tests/unit_tests/uiwebview_tests.cpp +++ b/src/tests/unit_tests/uiwebview_tests.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -1941,7 +1942,7 @@ UTEST( UIWebView, RemoteImageIgnoredAfterNavigation ) { EXPECT_TRUE( documentScene->getRoot()->find( "new-doc" ) != nullptr ); Node* probeNode = documentScene->getRoot()->find( "probe" ); ASSERT_TRUE( probeNode != nullptr && probeNode->isType( UI_TYPE_HTML_IMAGE ) ); - EXPECT_TRUE( probeNode->asType()->getDrawable() != nullptr ); + EXPECT_TRUE( probeNode->asType()->getDrawable() != nullptr ); Engine::destroySingleton(); } diff --git a/src/tools/eeiv/eeiv.cpp b/src/tools/eeiv/eeiv.cpp index 43059fbb4..80cd5c6af 100644 --- a/src/tools/eeiv/eeiv.cpp +++ b/src/tools/eeiv/eeiv.cpp @@ -35,7 +35,6 @@ App::App( int argc, char* argv[] ) : App::~App() { if ( mUIApplication ) { - saveConfig(); Http::Pool::getGlobal().clear(); Http::setThreadPool( nullptr ); } @@ -193,6 +192,7 @@ bool App::init() { contextSettings ); if ( !getWindow() || !getWindow()->isOpen() ) return false; + setBackgroundColor( Color::Black ); ResourceScope& resourceScope = *mUIApplication->getUI()->getResourceScope();