diff --git a/AGENTS.md b/AGENTS.md index 809adcf..e43686e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Ebox repository guidance -This repository is the standalone low-level Ebox package. It owns box nodes, style normalization, measurement, layout, rendering, buffer publication, selectors, incremental updates, Grid, and the optional Rust reflow module. The sibling `etaf` repository owns the higher-level text-application framework; `etaf-playground` and `ebox-playground` are separate example packages. +This repository is the standalone low-level Ebox package. It owns box nodes, style normalization, measurement, layout, rendering, semantic update planning, selector integration, Grid, and the optional Rust reflow module. Ebox plans publication scope and projects retained surface inputs; TP owns live buffer publication, diff execution, revisions, and rollback. ECSS owns selector parsing, matching, and cascade semantics while Ebox adapts nodes and candidate indexes. The sibling `etaf` repository owns the higher-level text-application framework; `etaf-playground` and `ebox-playground` are separate example packages. -Keep this repository independent from ETAF. Do not add Components, Runtime state, Data Controllers, UI controls, or application examples here. Public Ebox code may depend on its own modules only; private `ebox--*` functions are implementation details. +Keep this repository independent from ETAF. Do not add Components, Runtime state, Data Controllers, UI controls, or application examples here. Ebox may call ECSS and TP only through their public APIs; private `ebox--*` functions remain internal to this package. Use `apply_patch` for hand-edited changes. Keep each file lexically bound, document public APIs, preserve deterministic rendering, and add a focused regression test for behavior changes. Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` before claiming completion. Run `make native-rust-tests` when changing `native/` and run `make docs-contract-tests` when changing documentation or the active file boundary. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c31c2e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to Ebox are documented here. + +## 2.0.0 (Unreleased) + +### Added + +- Direct dependencies on the independent ECSS 0.1.0 cascade engine and TP 1.0.0 retained/reactive text runtime. +- ECSS-backed property schemas, selectors, stylesheets, inheritance, cascade layers, custom properties, computed values, and provenance for Box, Flex, and Grid nodes. +- Surface-scoped region handles through `ebox-region-resolve`, retained TP publication through `ebox-render-to-buffer` and `ebox-display-buffer`, and atomic handle/selector/viewport/scroll updates. +- Typography longhands and inherited foreground projection that keep text readable when child boxes override background colors. + +### Changed + +- ECSS is the only selector matcher and cascade winner. Ebox adapts node trees to ECSS subjects, interprets computed values for measurement/layout/paint, and classifies their layout impact. +- TP is the only live buffer writer. Ebox produces retained surface candidates and semantic dirty-owner plans; TP owns diff execution, marker-backed mounts, revisions, transactions, and rollback. +- Flex and Grid container/item declarations enter the same ECSS pipeline as ordinary boxes instead of maintaining a parallel style path. +- Region updates use opaque surface-scoped handles. The same declarative source may be mounted in multiple buffers without sharing live identity. + +### Removed + +- `ebox-pop-to-buffer`, `ebox-switch-to-buffer`, and the private erase-and-evaluate buffer writer they exposed. +- Ebox's duplicate selector parser/matcher and duplicate cascade winner. +- Bundled playground fixtures. Executable `.ebox` references live in the independent `ebox-playground` package. + +### Migration + +- Install ECSS 0.1.0 and TP 1.0.0, in either order, before Ebox 2.0.0. +- Replace numeric region-id mutation with `ebox-region-resolve` followed by `ebox-region-update` on the returned handle. +- Replace `ebox-pop-to-buffer` or `ebox-switch-to-buffer` with `ebox-display-buffer`, or call `ebox-render-to-buffer` when display is controlled by the caller. +- Ebox does not scan previously rendered text to reconstruct runtime identity. Remount the declarative source through Ebox 2.0. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md new file mode 100644 index 0000000..d911675 --- /dev/null +++ b/CHANGELOG.zh-CN.md @@ -0,0 +1,32 @@ +# 变更日志 + +Ebox 的重要变化记录在这里。 + +## 2.0.0(未发布) + +### 新增 + +- 直接依赖独立的 ECSS 0.1.0 cascade engine 与 TP 1.0.0 retained/reactive text runtime。 +- Box、Flex 与 Grid 节点统一使用 ECSS property schema、selector、stylesheet、inheritance、cascade layer、custom property、computed value 与 provenance。 +- 通过 `ebox-region-resolve` 提供 surface-scoped region handle;`ebox-render-to-buffer` 与 `ebox-display-buffer` 统一使用 retained TP publication;handle、selector、viewport 与 scroll 更新加入同一原子事务。 +- 增加 typography longhand 与继承 foreground 的投影;子盒子覆盖背景色时仍能保持文字可读。 + +### 变更 + +- ECSS 是唯一 selector matcher 与 cascade winner。Ebox 只把节点树适配为 ECSS subject,解释 computed value 的测量、布局与绘制后果,并分类 layout impact。 +- TP 是唯一 live buffer writer。Ebox 只生成 retained surface candidate 与语义 dirty-owner plan;diff execution、marker-backed mount、revision、transaction 与 rollback 都属于 TP。 +- Flex/Grid container 与 item declaration 和普通 box 一样进入同一条 ECSS pipeline,不再维护平行 style path。 +- Region 更新改用 opaque、surface-scoped handle;同一份声明式 source 可以挂载到多个 buffer,而不共享 live identity。 + +### 删除 + +- `ebox-pop-to-buffer`、`ebox-switch-to-buffer`,以及它们暴露的私有 erase-and-evaluate buffer writer。 +- Ebox 内重复的 selector parser/matcher 与 cascade winner。 +- 仓库内置 playground fixture;可执行 `.ebox` reference 已迁到独立 `ebox-playground` 包。 + +### 迁移 + +- ECSS 0.1.0 与 TP 1.0.0 互相独立,安装顺序任意;两者都安装后再安装 Ebox 2.0.0。 +- 数字 region id 更新改为先调用 `ebox-region-resolve`,再把返回的 handle 交给 `ebox-region-update`。 +- `ebox-pop-to-buffer` 或 `ebox-switch-to-buffer` 改为 `ebox-display-buffer`;如果显示行为由调用方控制,则使用 `ebox-render-to-buffer`。 +- Ebox 不扫描历史渲染文本来重建 runtime identity;请通过 Ebox 2.0 重新 mount 声明式 source。 diff --git a/Makefile b/Makefile index 1e7536b..4051362 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,15 @@ EMACS ?= emacs CARGO ?= cargo TP_DIR ?= ../tp +ECSS_DIR ?= ../new-architecture/ecss LOAD_EXTRA ?= -EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)' +EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(ECSS_DIR) -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)' NATIVE_MANIFEST = native/Cargo.toml NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release -.PHONY: all check ci load compile test checkdoc core-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install +.PHONY: all check ci load compile test checkdoc core-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install all: check @@ -52,6 +53,9 @@ dsl-tests: flex-tests: $(EMACS_BATCH) -l tests/ebox-flex-tests.el -f ert-run-tests-batch-and-exit +performance-evaluator: surface-tests ebox-commit-tests + $(EMACS_BATCH) -l scripts/ebox-performance-evaluator.el -f ebox-performance-evaluator-batch + docs-contract-tests: $(EMACS_BATCH) -l tests/ebox-docs-contract-tests.el -f ert-run-tests-batch-and-exit diff --git a/README.md b/README.md index 955528b..108de27 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Ebox is a standalone, pixel-precise box and layout engine for Emacs. It provides ## Install and use -Install TP first, then install Ebox through a package manager so its declared dependency is resolved. For sibling source checkouts, place both directories on `load-path`: +ECSS and TP are independent packages and may be installed in either order. Install both before Ebox, or let a package manager resolve Ebox's two declared dependencies. For sibling source checkouts, place all three directories on `load-path`: ```elisp +(add-to-list 'load-path "/path/to/github/ecss") (add-to-list 'load-path "/path/to/github/tp") (add-to-list 'load-path "/path/to/github/ebox") (require 'ebox) @@ -21,9 +22,9 @@ Install TP first, then install Ebox through a package manager so its declared de :bgcolor "#F4F6FB"))) ``` -The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` materializes propertized text through an ephemeral TP surface; `ebox-render-to-buffer` mounts a retained TP surface; and `ebox-commit` atomically updates that surface from a fresh root tree. Ebox copies declarative input before assigning runtime identity, so one source tree may be mounted in multiple buffers without transferring ownership. +The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` materializes propertized text through an ephemeral TP surface; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-display-buffer` displays that same retained-surface path; and `ebox-commit` atomically updates the mount from a fresh root tree. Ebox copies declarative input before assigning runtime identity, so one source tree may be mounted in multiple buffers without transferring ownership. It exposes no separate public macro that erases a live buffer and evaluates arbitrary body forms. -CSS-like selector strings are compiled to TP's structured selector AST. Ebox supplies logical node relations and indexed candidates; TP is the only selector matcher for tree queries, rendered-buffer queries, and later cascade rules. +CSS-like selector strings are compiled to ECSS's structured selector AST. Ebox supplies logical node relations and indexed candidates; ECSS is the only selector matcher and cascade engine for tree queries, rendered-buffer queries, and style rules. ## What belongs here @@ -44,9 +45,9 @@ ETAF belongs in the sibling package. Install it separately when you need the uni | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Formatting contexts and layout algorithms. | | `ebox-surface.el` | Ebox candidate projection plus TP surface mount/update and atomic Ebox runtime-state participation. | | `ebox-buffer-backend.el`, `ebox-incremental.el` | Layout dirty planning, marker indexes, and specialized local update paths. | -| `ebox-dsl.el`, `ebox-selector.el` | Standalone DSL and tree/runtime queries. | +| `ebox-dsl.el`, `ebox-selector.el` | Standalone DSL and ECSS-backed tree/runtime queries. | | `ebox-native-reflow.el`, `native/` | Optional native reflow boundary. | -| `examples/`, `tests/` | Ebox-only examples and regression tests. | +| `tests/` | Ebox-only regression and contract tests. | | `docs/user/`, `docs/maintainer/` | Long-lived English and Chinese documentation. | ## Verification @@ -58,6 +59,6 @@ make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make native-rust-tests ``` -Run the smallest focused target first (`make grid-tests`, `make surface-tests`, `make dsl-tests`, or `make visual-check-tests`), then run `make check` for shared rendering or public API changes. A sibling TP checkout is found at `../tp` by default; override `TP_DIR` when it lives elsewhere. +Run the smallest focused target first (`make grid-tests`, `make surface-tests`, `make dsl-tests`, or `make visual-check-tests`), then run `make check` for shared rendering or public API changes. Sibling ECSS and TP checkouts are found at `../new-architecture/ecss` and `../tp` by default; override `ECSS_DIR` or `TP_DIR` when they live elsewhere. Read the [Ebox user guide](docs/user/ebox-user-guide.en.md) for public construction examples and the [current implementation reference](docs/maintainer/ebox-current-implementation-reference.en.md) for ownership boundaries and invariants. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f0af95..b8d1382 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,9 +4,10 @@ Ebox 是一个独立的 Emacs 像素级 Box 与布局引擎。它是同级 [ETAF ## 安装与使用 -先安装 TP,再通过包管理器安装 Ebox,让声明的依赖自动解析。使用同级源码 checkout 时,把两个目录都加入 `load-path`: +ECSS 与 TP 是互相独立的包,安装顺序任意。两者都安装后再安装 Ebox,或直接让包管理器解析 Ebox 声明的两个依赖。使用同级源码 checkout 时,把三个目录都加入 `load-path`: ```elisp +(add-to-list 'load-path "/path/to/github/ecss") (add-to-list 'load-path "/path/to/github/tp") (add-to-list 'load-path "/path/to/github/ebox") (require 'ebox) @@ -21,7 +22,9 @@ Ebox 是一个独立的 Emacs 像素级 Box 与布局引擎。它是同级 [ETAF :bgcolor "#F4F6FB"))) ``` -公共模型是一棵 Ebox 节点树。`ebox-create` 构造叶子或包装节点;`ebox-column`、`ebox-row`、`ebox-flex` 和 `ebox-grid` 组合节点;`ebox-render` 返回带属性文本;`ebox-render-to-buffer` 将结果发布到 buffer;`ebox-commit` 使用新的根树更新已有 runtime。 +公共模型是一棵 Ebox 节点树。`ebox-create` 构造叶子或包装节点;`ebox-column`、`ebox-row`、`ebox-flex` 和 `ebox-grid` 组合节点;`ebox-render` 通过临时 TP surface 生成带属性文本;`ebox-render-to-buffer` 挂载 retained TP surface;`ebox-display-buffer` 展示同一条 retained-surface 路径;`ebox-commit` 使用新的根树原子更新该 mount。Ebox 会在分配 runtime identity 前复制声明式输入,因此同一 source tree 可以挂载到多个 buffer,而不转移所有权。公共 API 不再提供另一套擦除 live buffer 后执行任意 BODY 的宏。 + +CSS-like selector 字符串会编译为 ECSS 的 structured selector AST。Ebox 提供逻辑节点关系与索引候选;对于 tree query、rendered-buffer query 和样式规则,ECSS 是唯一 selector matcher 与 cascade engine。 ## 本包负责什么 @@ -41,10 +44,10 @@ ETAF 属于同级独立包。当你需要统一 View 语法、Component、响应 | `ebox-style.el`、`ebox-tree.el`、`ebox-measure.el` | 样式、树和测量模型。 | | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Formatting Context 与布局算法。 | | `ebox-surface.el` | 将 Ebox runtime/layout 纯投影为通用 TP surface plan。 | -| `ebox-buffer-backend.el`、`ebox-incremental.el` | Buffer 发布与增量更新。 | -| `ebox-dsl.el`、`ebox-selector.el` | 独立 DSL 与树/runtime 查询。 | +| `ebox-buffer-backend.el`、`ebox-incremental.el` | Render-string backend 与 Ebox 增量规划。 | +| `ebox-dsl.el`、`ebox-selector.el` | 独立 DSL 与基于 ECSS 的树/runtime 查询。 | | `ebox-native-reflow.el`、`native/` | 可选 native reflow 边界。 | -| `examples/`、`tests/` | 只属于 Ebox 的示例和回归测试。 | +| `tests/` | 只属于 Ebox 的回归与契约测试。 | | `docs/user/`、`docs/maintainer/` | 长期维护的中英文文档。 | ## 验证 @@ -56,6 +59,6 @@ make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make native-rust-tests ``` -小范围修改先运行对应目标(例如 `make grid-tests`、`make surface-tests`、`make dsl-tests` 或 `make visual-check-tests`);共享渲染或公共 API 修改后运行完整的 `make check`。默认从 `../tp` 查找同级 TP checkout;若路径不同可覆盖 `TP_DIR`。 +小范围修改先运行对应目标(例如 `make grid-tests`、`make surface-tests`、`make dsl-tests` 或 `make visual-check-tests`);共享渲染或公共 API 修改后运行完整的 `make check`。默认从 `../new-architecture/ecss` 与 `../tp` 查找同级 ECSS 和 TP checkout;若路径不同可覆盖 `ECSS_DIR` 或 `TP_DIR`。 公共构造示例见 [Ebox 用户指南](docs/user/ebox-user-guide.zh.md),职责边界与不变量见 [当前实现参考](docs/maintainer/ebox-current-implementation-reference.zh.md)。 diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 6525439..cb822a9 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -9,15 +9,16 @@ This is the maintainer entry point for the standalone Ebox repository. It descri 3. Read `docs/user/ebox-user-guide.en.md` for public construction patterns. 4. Read this document for ownership and verification. 5. Read `docs/maintainer/ebox-incremental-update-contract.en.md` before changing publication or patch planning. +6. Read `docs/maintainer/ebox-performance-architecture-analysis.en.md` before changing performance, retained surfaces, or incremental data flow. ## Active source map | File | Owns | | --- | --- | -| `ebox.el` | Public facade, construction helpers, rendering, buffer entry points, scrolling, commit, and byte compilation. | +| `ebox.el` | Public facade, construction helpers, rendering, TP-backed buffer entry points, scrolling, commit, and byte compilation. | | `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. | -| `ebox-style.el` | Property registration, aliases, shorthand expansion, computed style, colors, borders, and dirty effects. | -| `ebox-tree.el` | Node traversal, logical child access, TP subject adaptation, identity, parent paths, keys, and tree snapshots. | +| `ebox-style.el` | ECSS property schemas, declarations and cascade, shorthand expansion, computed style, colors, borders, and dirty effects. | +| `ebox-tree.el` | Node traversal, logical child access, ECSS subject adaptation, identity, parent paths, keys, and tree snapshots. | | `ebox-measure.el` | Display-sensitive character/face/pixel measurement and measurement caches. | | `ebox-fragment.el` | Layout fragments, signatures, snapshots, spans, and dirty-kind facts. | | `ebox-render-context.el` | Render-local context and publication inputs. | @@ -28,12 +29,12 @@ This is the maintainer entry point for the standalone Ebox repository. It descri | `ebox-buffer-backend.el` | Propertized render-string construction, display spaces/borders, and existing-slot shaping. | | `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, pure commit preparation, and reports. | | `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. | -| `ebox-selector.el` | CSS-like string parsing to TP selector ASTs, indexed candidate lookup, and tree/runtime query handles. | +| `ebox-selector.el` | CSS-like string parsing to ECSS structured selector ASTs, indexed candidate lookup, and tree/runtime query handles. | | `ebox-native-reflow.el` | Optional native module loading/build commands, ABI checks, bounded sessions, and Elisp fallback. | The package intentionally does not include application Components, UI controls, reactive data, or a playground implementation. Those are sibling-package responsibilities. -The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/ebox-core-render-tests.el`, `tests/ebox-grid-tests.el`, `tests/ebox-commit-tests.el`, `tests/ebox-surface-tests.el`, `tests/ebox-dsl-tests.el`, `tests/ebox-flex-tests.el`, `tests/ebox-selector-tests.el`, `tests/ebox-package-tests.el`, `tests/ebox-visual-check-tests.el`, `tests/ebox-docs-contract-tests.el`, `tests/ebox-ci-contract-tests.el`, `native/Cargo.toml`, `native/Cargo.lock`, `native/build.rs`, `native/vendor/emacs-30/emacs-module.h`, `native/src/lib.rs`, `native/src/layout.rs`, `native/c/ebox_module.c`, `scripts/ebox-package-lint.el`, and `scripts/ebox-visual-check.el`. +The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/ebox-core-render-tests.el`, `tests/ebox-grid-tests.el`, `tests/ebox-commit-tests.el`, `tests/ebox-surface-tests.el`, `tests/ebox-dsl-tests.el`, `tests/ebox-flex-tests.el`, `tests/ebox-selector-tests.el`, `tests/ebox-package-tests.el`, `tests/ebox-visual-check-tests.el`, `tests/ebox-docs-contract-tests.el`, `tests/ebox-ci-contract-tests.el`, `native/Cargo.toml`, `native/Cargo.lock`, `native/build.rs`, `native/vendor/emacs-30/emacs-module.h`, `native/src/lib.rs`, `native/src/layout.rs`, `native/c/ebox_module.c`, `scripts/ebox-package-lint.el`, `scripts/ebox-visual-check.el`, and `scripts/ebox-performance-evaluator.el`. ## Runtime model @@ -70,17 +71,17 @@ Caller-owned Source Tree - Public Ebox nodes are data; `ebox--*` names are private. - A one-element horizontal list such as `'(420)` denotes pixels; ordinary horizontal numbers denote character columns. -- `ebox-render` uses an ephemeral TP surface and does not publish to a buffer; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-commit` prepares Ebox semantics and updates that surface through TP. +- `ebox-render` uses an ephemeral TP surface and does not publish to a buffer; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-display-buffer` displays that same retained-surface path; and `ebox-commit` prepares Ebox semantics and updates the mount through TP. Ebox exposes no parallel public macro that erases a live buffer and evaluates arbitrary body forms. - Declarative input remains caller-owned. Live mount/commit assigns identity only on a surface-owned copy, so the same source can back multiple buffers. - Logical `:id` values resolve through `ebox-region-resolve` to opaque surface-scoped handles. Handles, not source-tree numeric ids, distinguish the same logical region mounted in different buffers. - A failed candidate leaves the previous buffer, runtime identity, and report intact. - Keys are local to siblings; visible strings are never used as identity. -- Ebox owns logical node-to-subject adaptation and id/class/type candidate indexes; TP's public structured matcher is the only selector truth source. Subjects expose built-in id/key plus explicit `:selector-attributes`, never visible content, layout state, or runtime containers. +- Ebox owns logical node-to-subject adaptation and id/class/type candidate indexes; ECSS's public structured matcher is the only selector truth source. Subjects expose built-in id/key plus explicit `:selector-attributes`, never visible content, layout state, or runtime containers. - `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`. - Buffer coordinates belong to the generation that produced them and must be refreshed after mutation. - Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness. - Loading Ebox never builds or installs the optional Rust module. -- Phase 9 is complete: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Phase 10 has unified selector matching under TP; Ebox computes dirty/layout ownership and joins its opaque runtime state to the same rollback-capable transaction, with no second live buffer executor. +- Phase 9 is complete: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Phase 10 has unified selector parsing, matching, and cascade under ECSS; Ebox computes dirty/layout ownership and joins its opaque runtime state to the TP rollback-capable transaction, with no second live buffer executor. - Successful reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP physical operation counts, surface revision, scoped/full-root facts, and retained-object reconciliation counts. ## Grid contract @@ -108,6 +109,7 @@ make dsl-tests make flex-tests make docs-contract-tests make ci-contract-tests +make performance-evaluator make visual-check make native-rust-tests make native-build diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index e4f9514..440863b 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -9,15 +9,16 @@ 3. 读 `docs/user/ebox-user-guide.zh.md` 了解公共构造方式。 4. 读本文了解所有权和验证方式。 5. 修改发布或 patch 规划前,读 `docs/maintainer/ebox-incremental-update-contract.zh.md`。 +6. 修改性能、retained surface 或增量数据流前,读 `docs/maintainer/ebox-performance-architecture-analysis.zh.md`。 ## Active 源码清单 | 文件 | 负责内容 | | --- | --- | -| `ebox.el` | 公共门面、构造辅助函数、渲染、buffer 入口、滚动、commit 与 byte compile。 | +| `ebox.el` | 公共门面、构造辅助函数、渲染、基于 TP 的 buffer 入口、滚动、commit 与 byte compile。 | | `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 | -| `ebox-style.el` | 属性注册、别名、shorthand 展开、computed style、颜色、border 和 dirty effect。 | -| `ebox-tree.el` | 节点遍历、逻辑子节点访问、TP subject 适配、identity、父路径、key 和树 snapshot。 | +| `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 | +| `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 | | `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 | | `ebox-fragment.el` | 布局 fragment、signature、snapshot、span 和 dirty kind 事实。 | | `ebox-render-context.el` | render-local context 与发布输入。 | @@ -28,12 +29,12 @@ | `ebox-buffer-backend.el` | 带文本属性的渲染字符串构造、display space/border 与既有 slot 整形。 | | `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、纯 commit 准备和报告。 | | `ebox-dsl.el` | 数据型 `.ebox` form,以及向公共节点的 lowering。 | -| `ebox-selector.el` | 把 CSS-like 字符串解析为 TP selector AST,利用索引缩小候选,并返回 tree/runtime query handle。 | +| `ebox-selector.el` | 把 CSS-like 字符串解析为 ECSS structured selector AST,利用索引缩小候选,并返回 tree/runtime query handle。 | | `ebox-native-reflow.el` | 可选 native 模块加载/构建、ABI 校验、受限 session 和 Elisp fallback。 | 本包有意不包含应用 Component、UI control、响应式 data 或 playground 实现;它们属于同级包。历史应用性能记录器和 native reflow 评估器也不属于独立 Ebox 的发布边界;Ebox 只保留 native 模块本身、Rust 构建输入和可重复的构建检查。 -active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-core-render-tests.el`、`tests/ebox-grid-tests.el`、`tests/ebox-commit-tests.el`、`tests/ebox-surface-tests.el`、`tests/ebox-dsl-tests.el`、`tests/ebox-flex-tests.el`、`tests/ebox-selector-tests.el`、`tests/ebox-package-tests.el`、`tests/ebox-visual-check-tests.el`、`tests/ebox-docs-contract-tests.el`、`tests/ebox-ci-contract-tests.el`、`native/Cargo.toml`、`native/Cargo.lock`、`native/build.rs`、`native/vendor/emacs-30/emacs-module.h`、`native/src/lib.rs`、`native/src/layout.rs`、`native/c/ebox_module.c`、`scripts/ebox-package-lint.el` 和 `scripts/ebox-visual-check.el`。 +active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-core-render-tests.el`、`tests/ebox-grid-tests.el`、`tests/ebox-commit-tests.el`、`tests/ebox-surface-tests.el`、`tests/ebox-dsl-tests.el`、`tests/ebox-flex-tests.el`、`tests/ebox-selector-tests.el`、`tests/ebox-package-tests.el`、`tests/ebox-visual-check-tests.el`、`tests/ebox-docs-contract-tests.el`、`tests/ebox-ci-contract-tests.el`、`native/Cargo.toml`、`native/Cargo.lock`、`native/build.rs`、`native/vendor/emacs-30/emacs-module.h`、`native/src/lib.rs`、`native/src/layout.rs`、`native/c/ebox_module.c`、`scripts/ebox-package-lint.el`、`scripts/ebox-visual-check.el` 和 `scripts/ebox-performance-evaluator.el`。 ## 运行时模型 @@ -70,17 +71,17 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor - 公共 Ebox 节点是数据;`ebox--*` 名称是私有实现。 - `'(420)` 这样的单元素横向 list 表示像素;普通横向数字表示字符列。 -- `ebox-render` 使用临时 TP surface,不发布到 buffer;`ebox-render-to-buffer` 挂载 retained TP surface;`ebox-commit` 准备 Ebox 语义后通过 TP 更新该 surface。 +- `ebox-render` 使用临时 TP surface,不发布到 buffer;`ebox-render-to-buffer` 挂载 retained TP surface;`ebox-display-buffer` 展示的也是这条 retained-surface 路径;`ebox-commit` 准备 Ebox 语义后通过 TP 更新该 mount。Ebox 不再公开另一套擦除 live buffer 后执行任意 BODY 的宏。 - 声明式输入始终由调用者拥有。live mount/commit 只在 surface-owned copy 上分配 identity,因此同一 source 可以挂载到多个 buffer。 - 逻辑 `:id` 通过 `ebox-region-resolve` 解析为不透明、surface-scoped 的 handle;同一逻辑 region 挂载到多个 buffer 时,由 handle 而不是 source-tree 数字 id 区分。 - 候选失败时必须保留之前的 buffer、runtime identity 和报告。 - Key 只在兄弟节点中有效;不能用可见字符串作为 identity。 -- Ebox 只拥有逻辑 node-to-subject 适配与 id/class/type 候选索引;TP 的公共结构化 matcher 是唯一 selector 真相源。subject 只暴露内建 id/key 和显式 `:selector-attributes`,绝不暴露可见 content、布局状态或 runtime 容器。 +- Ebox 只拥有逻辑 node-to-subject 适配与 id/class/type 候选索引;ECSS 的公共结构化 matcher 是唯一 selector 真相源。subject 只暴露内建 id/key 和显式 `:selector-attributes`,绝不暴露可见 content、布局状态或 runtime 容器。 - `owner-rerender` 范围大于 `span-patch`,`span-patch` 大于 `paint-patch`。 - Buffer 坐标属于生成它的 generation,变更后必须重新获取。 - Grid 使用普通测量与渲染流水线;native reflow 可以拒绝不适合的树并回退到 Elisp,正确性不变。 - 加载 Ebox 不会构建或安装可选 Rust 模块。 -- Phase 9 已完成:首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Phase 10 已把 selector 匹配统一到 TP;Ebox 只计算 dirty/layout owner,并让不透明 runtime state 参与同一个可 rollback transaction,不存在第二个 live buffer executor。 +- Phase 9 已完成:首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Phase 10 已把 selector 解析、匹配与 cascade 统一到 ECSS;Ebox 只计算 dirty/layout owner,并让不透明 runtime state 参与 TP 的可 rollback transaction,不存在第二个 live buffer executor。 - 成功报告会保留 Ebox 语义 strategy 与 planned publication scope,再加入 `:publication-scope tp-surface`、TP 物理 operation 数、surface revision、scoped/full-root 事实和 retained-object reconciliation 统计。 ## Grid 合同 @@ -106,6 +107,7 @@ make dsl-tests make flex-tests make docs-contract-tests make ci-contract-tests +make performance-evaluator make visual-check make native-rust-tests make native-build diff --git a/docs/maintainer/ebox-incremental-update-contract.en.md b/docs/maintainer/ebox-incremental-update-contract.en.md index 22fa661..0e9aac5 100644 --- a/docs/maintainer/ebox-incremental-update-contract.en.md +++ b/docs/maintainer/ebox-incremental-update-contract.en.md @@ -12,7 +12,7 @@ This document defines the low-level publication contract. It is independent of E 6. TP owns generic retained-surface reconciliation, mount/index state, all live buffer diff execution, revision changes, and rollback for initial mount, declarative commit, handle update, viewport/theme update, and scroll update. 7. `ebox-buffer-backend.el` only builds and reshapes propertized render strings. It does not own live markers or a buffer mutation executor. -No layer may infer application state from visible buffer text. No public caller may mutate a published tree in place. +No layer may infer application state from visible buffer text. No public caller may mutate a published tree in place. Every public live-publication route, including `ebox-render-to-buffer`, its `ebox-display-buffer` display wrapper, and commit/update entry points, uses a retained TP surface; the public facade exposes no erase-and-evaluate-body buffer writer. ## Commit lifecycle @@ -35,6 +35,14 @@ The candidate is either fully published or discarded. A failed render, TP write, The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. These names describe Ebox semantic owner scope; TP alone computes and executes the physical text/property diff. A patch must not silently widen its semantic scope. Reports preserve the Ebox strategy and planned scope while separately recording TP's actual surface operations and revision. +## Strict retained viewport reflow + +`viewport-reflow` is an explicit retained projection kind for a narrow, proof-driven viewport resize path. The planner may select it only when all of the following hold: the plan is one root-owned geometry `owner-rerender`; the old and candidate roots are the same retained root; no dirty entry changes children; the node-key set, region-id set, and parent table are unchanged; neither the old surface nor the current style environment requires cascade or inline-inheritance recomputation; there is no scroll state; the root has no visible-overflow scope; and the old surface has a non-empty retained node-object table. Width-only, height-only, and both-axis changes use the same proof. + +When selected, the candidate keeps the published Ebox topology and reuses the retained TP node-object subtree. The producer still reruns viewport-sensitive layout and materializes the new surface plan, and TP remains responsible for the scoped buffer diff, revision, stable identity, and atomic rollback. `viewport-reflow` must never be reported for a topology, cascade, inheritance, scroll, overflow, or display-signature change merely because the final text patch is small. + +If any proof condition fails, the update uses the ordinary safe projection path. That fallback may do broader node projection and TP reconciliation, but it preserves the same output, identity, publication, and rollback contract. Tests in `tests/ebox-surface-tests.el` cover the retained width/height/both-axis path, each unsafe fallback trigger, and publication failure after candidate preparation. + ## Identity and coordinates Keys are local to a sibling collection. Declarative source trees never own live TP objects, markers, or buffer coordinates. A live logical `:id` resolves through `ebox-region-resolve` to an opaque handle tied to one TP surface object; mounting the same source in two buffers therefore yields distinct handles. Region and host-ref positions are generation-bound; after a buffer mutation, callers must obtain fresh positions from the public accessor. Buffer markers, display spans, and text properties are publication facts, not source-tree identity. @@ -49,4 +57,4 @@ Every update path must prove: - bounded patch scope when a local update is possible; - correct fallback behavior when native reflow is unavailable. -Use `tests/ebox-commit-tests.el`, `tests/ebox-core-render-tests.el`, and `tests/ebox-grid-tests.el` for focused coverage, then run `make check`. +Use `tests/ebox-commit-tests.el`, `tests/ebox-core-render-tests.el`, and `tests/ebox-grid-tests.el` for focused update coverage. `tests/ebox-package-tests.el` guards the live-buffer publication boundary; then run `make check`. diff --git a/docs/maintainer/ebox-incremental-update-contract.zh.md b/docs/maintainer/ebox-incremental-update-contract.zh.md index 722f85e..0cac0fa 100644 --- a/docs/maintainer/ebox-incremental-update-contract.zh.md +++ b/docs/maintainer/ebox-incremental-update-contract.zh.md @@ -12,7 +12,7 @@ 6. TP 拥有首次 mount、声明式 commit、handle 更新、viewport/theme 更新和 scroll 更新的通用 retained-surface reconciliation、mount/index 状态、全部 live buffer diff 执行、revision 变更和 rollback。 7. `ebox-buffer-backend.el` 只构造和整形带文本属性的渲染字符串,不拥有 live marker 或 buffer mutation executor。 -任何层都不能从可见 buffer 文本推断应用状态;公共调用者不能原地修改已经发布的树。 +任何层都不能从可见 buffer 文本推断应用状态;公共调用者不能原地修改已经发布的树。每条公共 live 发布路径,包括 `ebox-render-to-buffer`、其展示包装 `ebox-display-buffer` 以及 commit/update 入口,都使用 retained TP surface;公共门面不再提供擦除 buffer 后执行任意 BODY 的 writer。 ## Commit 生命周期 @@ -35,6 +35,14 @@ 规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。这些名称描述的是 Ebox 的语义 owner 范围;只有 TP 负责计算并执行物理文本/属性 diff。Patch 不得静默扩大语义范围。报告保留 Ebox strategy 与 planned scope,同时单独记录 TP 的实际 surface operation 和 revision。 +## 严格 retained viewport reflow + +`viewport-reflow` 是一个明确的 retained projection kind,只用于范围很窄、由 proof 驱动的 viewport resize 路径。只有以下条件全部满足时,规划器才能选择它:plan 是一个 root-owned geometry `owner-rerender`;旧 root 与 candidate root 是同一个 retained root;没有 dirty entry 修改 children;node-key set、region-id set 和 parent table 都不变;旧 surface 和当前 style environment 都不需要 cascade 或 inline-inheritance 重新计算;没有 scroll state;root 没有 visible-overflow scope;旧 surface 存在非空 retained node-object table。仅改变 width、仅改变 height 以及同时改变两个轴使用同一套 proof。 + +选择该路径后,candidate 保留已发布的 Ebox topology,并复用 retained TP node-object subtree。Producer 仍会重新执行 viewport-sensitive layout 并生成新的 surface plan;TP 继续负责 scoped buffer diff、revision、stable identity 和原子 rollback。即使最终文本 patch 很小,topology、cascade、inheritance、scroll、overflow 或 display-signature 变化也不能报告为 `viewport-reflow`。 + +任意 proof 条件失败时,更新必须使用普通安全 projection 路径。该 fallback 可以执行更宽的 node projection 和 TP reconciliation,但必须保持相同的输出、identity、publication 和 rollback 合同。`tests/ebox-surface-tests.el` 覆盖 retained width/height/both-axis 路径、每个不安全 fallback 触发条件,以及 candidate preparation 之后的 publication failure。 + ## Identity 与坐标 Key 只在同级兄弟集合内有效。声明式 source tree 不拥有 live TP object、marker 或 buffer 坐标。live 逻辑 `:id` 通过 `ebox-region-resolve` 解析为绑定某个 TP surface object 的不透明 handle;同一 source 挂载到两个 buffer 时会得到不同 handle。Region 与 host-ref 位置绑定于 generation;buffer 变更后调用者必须通过公共 accessor 重新获得位置。Buffer marker、display span 和 text property 是发布事实,不是 source tree identity。 @@ -49,4 +57,4 @@ Key 只在同级兄弟集合内有效。声明式 source tree 不拥有 live TP - 可以局部更新时 patch 范围受控; - native reflow 不可用时 fallback 正确。 -聚焦测试使用 `tests/ebox-commit-tests.el`、`tests/ebox-core-render-tests.el` 和 `tests/ebox-grid-tests.el`,然后运行 `make check`。 +更新行为的聚焦测试使用 `tests/ebox-commit-tests.el`、`tests/ebox-core-render-tests.el` 和 `tests/ebox-grid-tests.el`;`tests/ebox-package-tests.el` 守护 live-buffer 发布边界;然后运行 `make check`。 diff --git a/docs/maintainer/ebox-performance-architecture-analysis.en.md b/docs/maintainer/ebox-performance-architecture-analysis.en.md new file mode 100644 index 0000000..b1e4c49 --- /dev/null +++ b/docs/maintainer/ebox-performance-architecture-analysis.en.md @@ -0,0 +1,361 @@ +# Ebox Performance Architecture Diagnosis and Redesign Direction + +> Status: Architecture diagnosis and implementation checkpoint. This document records the performance gap between the current refactored `ebox` and the historical `emacs-box`, the evidence boundary, and the high-level redesign direction. Five narrow slices are implemented; the retained mounted-update architecture is not yet considered generally repaired. +> +> Date: 2026-08-07 +> +> Revision: 2026-08-08. Following an independent architecture review, this document separates style cascade, projection, candidate preparation, pure render, and retained-TP coupling, lowers confidence where direct stage timing is still missing, and records isolated pure-materialization, candidate-handoff, partial-line scoped-projection, strict retained-viewport-reflow, and fixed-footprint copy-on-write checkpoints. + +## Question + +Why does the current refactored Ebox fail to match the performance of the pre-refactor `emacs-box` during dynamic content updates, viewport resize, and pure `ebox-render`? The data-flow and ownership causes must be established before optimizing individual functions. + +## Conclusion + +The regression is not primarily one function becoming slower. In the baseline retained data flow, computation scope is bound to the wrong whole-tree path: current Ebox connects “materialize the final propertized string” and “maintain a retained, incrementally updateable TP surface with stable identity,” while triggering whole-tree style cascade, projection, and candidate preparation on paths whose scope has not been narrowed. + +Consequently, before the scoped slices, an update that ultimately needed one owner and one text operation still paid for whole-tree style computation, node projection, candidate preparation, and ownership/range construction first. The pre-slice path also repeated identity reconciliation. The partial-row checkpoint now removes the unnecessary projection/layout breadth for one stable owner shape, but unresolved paths can still expand; current stage data directly identifies style cascade and baseline projection as the largest measured costs, while it does not independently prove that TP identity, retain, or final publication is the single largest cost. + +The key distinction is that current’s “local patch” describes publication granularity, not computation granularity. In legacy, a local update is usually local both in computation and publication. In current, only the final publication step is local. The retained TP boundary should therefore be treated as the architectural scope coupling that creates this problem, not as an independently proven intrinsic bottleneck. A strict retained `viewport-reflow` slice now closes one measured case: it reuses the retained node-object subtree while rerunning viewport layout, and rejects cascade, inheritance, scroll, visible-overflow, and topology cases back to the ordinary safe path. + +## Scope and terminology + +- “current” means the standalone refactored `ebox` repository. +- “legacy” means the sibling historical `emacs-box` source tree. +- “pure materialization” means producing a propertized render string from a source tree without a live buffer, stable retained identity, or rollback requirement. +- “retained publication” means publishing a candidate to a mounted TP surface with stable identity, ownership, scope proof, revision, and rollback semantics. +- Absolute timings come from one local measurement environment. The architecture conclusion relies on stage proportions, call counts, and identical outputs rather than on one absolute threshold. + +## Stage-level evidence + +### Dynamic content update + +The same approximately 1712-node `dynamic-update-reference.ebox` fixture was used; the current parser constructs a tree of the same scale. The fixture has no stylesheet rule but does contain inline inherited style. + +| Item | Current | Legacy | Direct observation | +| --- | ---: | ---: | --- | +| Total time | 12.127969s | 0.073844s | Output length is 1177 and SHA-256 is identical | +| Projection-start stage | 10.882838s | Not present | Current calls `ensure-node-tree` 1712 times; the interval boundary still needs a probe contract | +| Style computation | 1262 calls, about 8.99s | No equivalent stage | `ecss-compute-style` runs 1262 times, about 8.95s | +| Final text operations | 1 | Local patch | Current report is still `span-patch` with one dirty owner | +| TP structure statistics | 1716 reconciled objects; 378 property operations | Direct buffer/region path | TP publication is not the dominant source of time | + +After current completes whole-tree projection and style computation, it needs only one local owner patch. Legacy formats only the target content and performs the local patch. This rules out text length and the text SHA-256 recorded by the current probe as the primary explanation, but the hash has not yet been shown to include complete text properties and therefore is not by itself an `equal-including-properties` proof. + +### Viewport resize + +For a `900 → 720` resize on the same fixture: + +- Current takes `12.447619s`; projection-start takes `11.305584s`; `ensure-node-tree` still runs 1712 times and 1262 styles are computed. +- Current has only one TP text operation and 361 property operations; the Ebox report is `owner-rerender` with one dirty owner and scope one. +- Legacy takes `0.404387s` and renders approximately 51 local nodes; output length and SHA-256 match current. + +Therefore, the name `owner-rerender` describes the final publication scope, not the scope of the preceding layout, style, candidate, and projection work. The strict retained viewport checkpoint now measures `node-projection=2`, `TP-object-preparation=5`, and `reconciled-objects=4` on the evaluator fixture; this is a proven safe subset, not evidence that every resize path has that closure. + +### Pure `ebox-render` + +On the same full fixture: + +- Current takes `12.420715s`; `ebox-surface--project` takes `12.293075s`; projection-start takes `11.556190s`; `ecss-compute-style` runs 1262 times for about `9.34s`. +- Current `tp-surface-materialize-string` accounts for nearly the entire total because it enters the retained-surface construction path. +- Legacy takes `0.710261s`, directly executing layout/render without an equivalent TP surface projection. +- Both implementations produce length 1183 with identical SHA-256. + +This shows that current `ebox-render` presents as a string-returning API but enters the retained-surface construction path internally. It establishes boundary coupling, but aggregate stages alone do not establish the independent cost of retained identity, bindings, or the ownership graph. + +### Stylesheet A/B + +On a 300-box tree without inline inheritance: + +| Condition | Total time | Ebox style calls | ECSS calls | Output | +| --- | ---: | ---: | ---: | --- | +| No stylesheet | 0.481574s | 0 | 0 | Same length and SHA-256 as the other run | +| Add one `box` rule | 2.973969s | 301 | 301 | Same length and SHA-256 as the other run | + +A stylesheet rule that does not change the final visual output is enough to switch the static tree onto a per-node cascade path. This makes “stylesheet active” an important trigger for style-computation expansion, rather than charging only for nodes whose effective style actually changed. + +The dynamic fixture is triggered by inline inherited style, while the A/B fixture is triggered by a stylesheet rule; these are not the same trigger path. The document currently shows that both paths enter style computation, but does not establish that one snapshot/invalidation repair will produce the same benefit for both. + +The three current totals are `12.127969s`, `12.447619s`, and `12.420715s`, varying by less than 3%; the corresponding legacy totals are `0.073844s`, `0.404387s`, and `0.710261s`. This supports a current fixed whole-tree cost that is weakly related to operation type and strongly related to tree size, but does not identify whether that cost comes from style, projection, candidate preparation, or retained construction. + +## Implemented checkpoint: isolated static materialization + +The first reversible architecture slice is now implemented for the narrow case where the source has no active stylesheet and does not require inline inherited-style computation. `ebox-render` creates an isolated candidate and render-side tables, then runs the raw layout/materialization path without constructing a retained TP object tree. Stylesheet-required pure renders and all mounted paths still use the retained surface path. + +The declared evaluator confirms both the optimization and the boundary: + +| Scenario | Elapsed | Relevant stage evidence | Correctness evidence | +| --- | ---: | --- | --- | +| Pure static | `0.047611s` | No node-projection, TP-object-preparation, or publication stage | Isolated output equals the TP-backed reference with `equal-including-properties` | +| Stylesheet pure | `0.171233s` | Cascade exercised; 21 `ecss-compute-style` calls | TP-backed output equality retained | +| Mounted content update | `0.061124s` | 3 node-projection calls, 5 TP-object-preparation calls | Mounted identity retained; `full-root=nil`, scope count `2`; TP reconciles 4 objects | +| Viewport resize | `0.044602s` | 28 node-projection calls, 58 TP-object-preparation calls | Root identity retained; `full-root=nil`, scope count `1`; TP reconciles 30 objects | + +The earlier same-evaluator comparison was `0.037040s` before the slice and `0.025286s` after it (about 32% lower). The fresh values above are environment samples, not a replacement for the relative checkpoint. The slice proves that the pure static boundary removes retained projection work without changing output; it does not prove that mounted scoped updates are local in computation. The candidate-handoff boundary is now separately addressed below, while projection and layout still need a scope reduction. + +## Implemented checkpoint: isolated candidate handoff + +Region and scroll updates now pass their already-isolated runtime candidate through the incremental planner and surface producer. The internal handoff skips a second structural copy, declarative-root validation, and runtime-identity reconciliation; viewport updates deliberately do not use this flag because they start from the published root and still require the ordinary candidate policy. + +The fresh evaluator and focused regression evidence are: + +| Scenario | Candidate evidence | Remaining computation scope | Correctness evidence | +| --- | --- | --- | --- | +| Mounted content update | One candidate structural copy; no duplicate validation or reconciliation | `ensure-node-tree` 0 calls, node-projection 3 calls, layout/fragment/ownership 4 calls, TP-object-preparation 5 calls | Surface tests 32/32; commit tests 4/4; exact propertized output; mounted identity retained; `full-root=nil`, scope count `2`; TP reconciles 4 objects | +| Scroll update | Isolated candidate handoff is accepted without changing the TP contract | No independent scroll stage breakdown yet | Surface regression passes; TP revision/publication behavior remains valid | +| Viewport resize | Ordinary published-root candidate path remains unchanged | `ensure-node-tree` 26 calls, layout/fragment/ownership 7 calls, TP-object-preparation 58 calls | Exact output; root identity retained; `full-root=nil`, scope count `1`; TP reconciles 30 objects | + +This checkpoint removes a duplicated candidate lifecycle. The partial-line slice below then narrows projection and layout for one stable owner shape; broader mounted paths can still index, project, lay out, and construct ownership beyond the final publication scopes. The next implementation work must generalize the affected layout/projection closure, not return to candidate helper micro-optimizations. + +## Implemented checkpoint: partial-line scoped projection + +Mounted span-patch now treats a partial-line owner as a set of existing parent slots rather than requiring the owner's spans to be contiguous. Each slot is shaped independently, while whole-line spans retain the contiguous-span safety proof. If span proof fails, the TP fallback reuses the already prepared surface root, node root, and object table instead of constructing a second surface context. Paint-only updates confirm the paint patch before span proof and text measurement, so they remain on the paint path. + +After the span proof succeeds, the mounted producer uses TP's `tp-object-reuse-subtree` for the previously proven unchanged node-object subtree. The candidate still carries the complete output plan and its content-range attachments, but the unchanged subtree is excluded from TP's touched/reconcile set. This is a proof-driven reuse path, not a generic omission: if the candidate topology, output footprint, parent slots, roles, or overflow signature changes, the normal projection and reconciliation path remains available. + +The latest evaluator and focused regression evidence are: + +| Scenario | Scope evidence | Correctness evidence | +| --- | --- | --- | +| Mounted partial-row content update | `ensure-node-tree=0`, node-projection `3`, layout/fragment/ownership `4`, TP-object-preparation `5` | Elapsed `0.061124s`; `equal-including-properties`; mounted identity retained; `full-root=nil`, scope count `2`; `span-patch` | +| TP retained bookkeeping for the mounted partial-row path | `reconciled-objects=4`, `created-objects=0`, `removed-objects=0` | The proven unchanged subtree is retained without generic reconciliation; scoped TP transaction remains valid; no full-root fallback | +| TP retained bookkeeping for viewport resize | `reconciled-objects=30`, `created-objects=0`, `removed-objects=0` | The ordinary owner-rerender path remains correct and scoped, but its retained closure is not yet narrowed | + +This is evidence that the affected-owner projection/layout boundary and retained-object bookkeeping can both be local for one partial-row update. It is not evidence that every structural, flex, overflow, viewport, or rollback-sensitive update has the same closure: the current viewport owner-rerender path still reconciles 30 objects. + +## Implemented checkpoint: strict retained viewport reflow + +Viewport changes now have a separate retained projection kind, `viewport-reflow`, for one deliberately narrow proof boundary. The candidate keeps the published root and reuses the retained TP node-object table; the producer reruns the complete viewport-sensitive layout and surface plan without calling `ensure-node-tree` or pretending the operation is a `span-patch`. TP then publishes the changed output through the retained root scope. + +The planner authorizes this path only when all of the following hold: the owner plan is one root-owned geometry rerender; no dirty entry changes children; the root, node-key set, region-id set, and parent table are unchanged; neither the previous nor current runtime requires an ECSS cascade; no scroll state exists; the tree has no visible overflow; and the previous surface has a retained node-object table. Height-only and width-plus-height changes are covered by the same proof. Active stylesheet rules, inline inheritance, scroll state, visible overflow, and publication failure are explicit regression cases; each falls back or rolls back through the ordinary TP transaction. + +The current evaluator and focused tests provide this checkpoint: + +| Scenario | Scope evidence | Correctness evidence | +| --- | --- | --- | +| Viewport resize on the evaluator fixture | `node-projection=1`, `layout/fragment/ownership=7`, `TP-object-preparation=5`, `ensure-node-tree=0`, `reconciled-objects=4` | Fresh evaluator sample `0.030237s`; exact runtime output; root identity retained; `full-root=nil`, scope count `1`; one text operation | +| Height-only and both-axis resize | Retained `viewport-reflow`; no node-tree ensure calls | `:viewport-axes` is respectively `height` and `both`; output width and publication remain correct | +| Unsafe viewport cases | Active stylesheet, inline inheritance, scroll, and visible overflow use ordinary projection | Surface suite proves `39/39`; commit suite proves `4/4`; no unsafe case is mislabeled `viewport-reflow` | +| Failed viewport publication | TP publication failure is injected after candidate preparation | Surface revision, signal values, client state, Ebox state, and buffer text all remain on the old generation | + +This closes the previously measured viewport over-computation for the evaluator fixture, but only inside this proof boundary. The broader architectural goal remains to establish a dependency-driven viewport damage closure for flex, scroll, cascade, overflow, and structural cases instead of widening this fast path speculatively. + +## Implemented checkpoint: fixed-footprint copy-on-write region candidate + +Mounted content-only updates now have one additional, deliberately narrow candidate path. When the update replaces string content in a box with a fixed numeric width, no content child node, no active cascade, and no scroll state, the incremental layer copies only the region owner and its ancestor path. The retained runtime tables are prepared with a local index delta, and the surface receives an explicit path-copied marker. This keeps candidate preparation in Ebox's planning layer while TP remains the owner of mounted identity, scoped publication, revision, and rollback. + +The path is admitted only after the existing span-patch identity and footprint proof, plus a line-count preflight against the formatted replacement. A multiline or wrapping replacement is promoted to an ordinary private structural copy before it reaches the surface. The surface therefore never clears attachments on shared published nodes and never owns an emergency fallback copy. + +| Scenario | Scope evidence | Correctness evidence | +| --- | --- | --- | +| Fixed-footprint mounted content update | Candidate preparation has no full `ebox--runtime-index`; the evaluator reports node-projection `1`, layout/fragment/ownership `4`, and TP-object-preparation `5`; the focused regression reports no second full structural copy | `equal-including-properties`; target mounted identity retained; `full-root=nil`, scope count `2`; strategy `span-patch`; evaluator and commit suites pass | +| Multiline or widened replacement | The line-count preflight rejects path sharing and promotes to a private candidate before surface projection | The rollback regression observes a private copy, injects publication failure, and verifies the old Ebox state, output, and published content remain unchanged | + +This is a proof-bounded COW slice, not a general candidate policy. Structural, flex, overflow, cascade, scroll, viewport, and other footprint-changing updates continue to use the ordinary correctness path until their affected closure and rollback depth are separately proven. + +## Current validation boundary + +The declared evaluator passed on 2026-08-08: `make performance-evaluator` ran 39/39 surface tests and 4/4 commit tests, and all four scenarios passed their output, identity, scoped-publication, and non-overlapping-stage checks. The fixed-footprint mounted scenario preserved `equal-including-properties`, retained the target object, and published with `full-root=nil` and scope count `2`; the viewport scenario published with `full-root=nil`, scope count `1`, and `reconciled-objects=4`. + +The broader `make check` baseline passed before the later COW checkpoint with 457/457 core tests, 15 grid tests, 4 commit tests, 32 surface tests, 4 visual-check tests, 11 package tests, 22 selector tests, 25 DSL tests, 60 flex tests, 4 documentation-contract tests, and 4 CI-contract tests. The current focused surface suite is 39/39, including fixed-footprint COW fallback, height/both-axis, and rollback cases. The earlier regressions are closed; the performance goal is not complete because broader viewport and mounted affected-closure proofs remain unfinished. + +## Resolved validation regressions + +The full-suite failures exposed two lifecycle-boundary bugs rather than invalid test expectations. First, `ebox--render-ephemeral-static` locally forced `ebox--defer-scroll-content-index` to nil, overriding the caller's deferred-scroll contract and adding the sentinel/lookahead line. The local override was removed so the caller owns that mode. Second, `ebox-incremental-surface-batch-root` returned the last batch candidate after the batch had been flushed. A following non-batch update then mutated the published root in place; old and new boxes aliased, the declarative dirty set became empty, and scoped publication received no retained owner. The accessor now returns a candidate only while the batch is active. The regressions pass without adding a scope fallback or changing expected behavior. + +## Measurement and equivalence limits + +- The start and end of `projection-start` are not yet defined. It is not safe to subtract style time from projection time to estimate non-style projection cost until candidate copy, style, layout, fragments, owner ranges, the ledger, identity reconciliation, retain, and publication have explicit boundaries. +- The count semantics for 1712 nodes, 1716 reconciled objects, 1262 style calls, and 301 calls on the 300-box fixture are not yet explained; root, wrapper, text-leaf, skipped-node, and cache-hit accounting must be made explicit. +- The earlier partial-row checkpoint reports `ensure-node-tree=0`, node-projection `3`, layout/fragment/ownership `4`, and TP `reconciled-objects=4`; the current fixed-footprint mounted evaluator reports node-projection `1`, layout/fragment/ownership `4`, and TP-object-preparation `5`, while the strict retained viewport checkpoint reports node-projection `1`, TP-object-preparation `5`, and `reconciled-objects=4`. Broader fallback paths may still report older whole-tree counts, so Ebox projection scope and TP retained bookkeeping must remain separate measurements. +- Historical baseline probes recorded length and SHA-256 without proving complete text properties. The current evaluator now uses `equal-including-properties` for the isolated and mounted comparisons; future scope changes must preserve that assertion. + +## Ranked root causes + +| Rank | Cause | Confidence | Evidence basis | +| --- | --- | ---: | --- | +| 1 | ECSS/reactive style cascade is eager and per-node | High | Dynamic update runs `ecss-compute-style` 1262 times for about 8.99s; pure render takes about 9.34s there; one visually inert rule triggers 301 ECSS computations | +| 2 | Projection/`ensure-node-tree` is often broader than the publication scope | High | The baseline probes showed 10.88s, 11.31s, and 11.56s projection-start intervals with 1712 `ensure-node-tree` calls; partial-row and strict viewport checkpoints now prove two safe closures with `ensure-node-tree=0`, while broader fallback paths remain unresolved | +| 3 | The `ebox-render` abstraction boundary is wrong | High | [ebox-layout.el:1444](../../ebox-layout.el#L1444) defaults to `tp-surface-materialize-string`; pure string callers still pay retained-surface projection cost | +| 4 | Candidate copy/index preparation remains broader than the publication scope outside proven paths | Medium | The duplicated region/scroll handoff is removed, and the fixed-footprint region path avoids a full runtime index; broader content, structural, and viewport paths still require their own closure proof | +| 5 | The retained TP boundary couples pure render, style, projection, and mounted update paths | Medium-high | Pure render entering the retained path is established; current data does not equate that coupling with the largest intrinsic TP identity/retain/publication cost | +| 6 | Ownership is represented and scanned at multiple layers | Medium-low | Fragments, owner ranges, TP plans, the property ledger, and text properties exist, but there is no independent timing for ownership construction/scanning | +| 7 | The underlying layout algorithm is universally slower | Low | Some no-stylesheet flex fixtures show current first mount at about 1.4–1.5s versus legacy at about 1.61–1.63s; “universally slower” is unsupported | + +## What the current evidence does not establish + +- `ebox-surface--project` and `tp-surface-materialize-string` are aggregate stages covering style, projection, fragments, ownership, and TP-related work; they cannot be treated as intrinsic TP cost. +- `property operations` counts (378 for content update and 361 for resize) are not timings; their performance contribution requires stage measurements. +- Legacy processing about 51 resize nodes suggests current scope may be too broad, but does not by itself prove that 51 is the correct minimum affected closure. +- Multiple ownership structures are a design risk worth testing, but the current evidence does not prove that they are all rescanned or that they can be safely merged. +- Full-tree candidate/index preparation may be a conservative correctness strategy; dependency, identity, and rollback evidence is needed to identify which remaining work is unnecessary. The duplicated copy/validation/reconciliation handoff has now been removed for isolated region and scroll candidates. + +## Code-path evidence + +The current retained data flow crosses these boundaries: + +- [ebox-surface.el:535](../../ebox-surface.el#L535) builds a TP projection for the whole tree. +- [ebox-surface.el:712](../../ebox-surface.el#L712) copies the candidate root and [ebox-surface.el:725](../../ebox-surface.el#L725) reconciles identity. +- [ebox-surface.el:760](../../ebox-surface.el#L760) installs per-node style bindings and [ebox-surface.el:775](../../ebox-surface.el#L775) reads/applies computed style. +- [ebox-surface.el:785](../../ebox-surface.el#L785) recursively calls `tp-object-ensure` and `tp-object-retain`. +- [ebox-surface.el:1236](../../ebox-surface.el#L1236) constructs and attaches owner ranges from fragments. +- [ebox-surface.el:1269](../../ebox-surface.el#L1269) converts fragments into shared text leaves and a TP surface plan. +- [ebox-surface.el:1627](../../ebox-surface.el#L1627) decides whether the style-required path is active from stylesheet state, inline inheritance, and previous cascade state. +- [ebox-incremental.el:5235](../../ebox-incremental.el#L5235) prepares candidate runtime state, the runtime index, and the dirty set. +- [ebox-incremental.el:5933](../../ebox-incremental.el#L5933) has an isolated-candidate branch that reuses the prepared root, while the remaining owner plan determines downstream projection scope. +- [ebox.el:3486](../../ebox.el#L3486) publishes region updates through a surface-scoped TP publication. +- [ebox.el:3694](../../ebox.el#L3694) sends buffer rendering into surface mount; [ebox-surface.el:381](../../ebox-surface.el#L381) creates signals, a producer, and a TP transaction. + +The corresponding legacy path is closer to “computation scope equals modification scope”: + +- `emacs-box/ebox.el:7195` directly modifies the target region box and enters the local dirty/patch path. +- `emacs-box/ebox.el:7399` formats only the target content before applying the local patch. +- `emacs-box/ebox.el:7578` directly renders, inserts, and refreshes markers/spans. +- Legacy `ebox-style.el` mainly expands local properties and classifies dirty work; it has no equivalent current ECSS stylesheet/reactive per-node cascade. + +## Evidence versus inference + +### Evidence + +- Current projection, style, candidate, and ownership stages account for most of the measured dynamic-update time. +- Current and legacy produce identical output lengths and SHA-256 values for content update, resize, and pure render comparisons. +- Current has a small final TP text-operation count and a small Ebox dirty-owner count despite large whole-tree prerequisite call counts. +- Stylesheet A/B produces per-node style/ECSS calls even when output is unchanged. +- The current code paths connect materialization, retained identity, style binding, and TP publication in one call chain. +- Current totals are nearly constant across three different operations while legacy varies substantially; this is corroboration for a fixed whole-tree cost, not an independent timing for one internal stage. +- The isolated candidate-handoff regression passes with one structural copy and zero duplicate validation/reconciliation; the earlier partial-row checkpoint reduced the mounted scenario to node-projection `3`, while the fixed-footprint COW checkpoint now measures node-projection `1` with layout/fragment/ownership `4` and TP-object-preparation `5`; the strict viewport checkpoint also measures node-projection `1`, TP-object-preparation `5`, and `reconciled-objects=4`. + +### Inference + +- The dominant problem is computation scope being larger than publication scope, not the efficiency of the final TP diff. +- Once a surface is cascade-required or needs retained identity, static and local paths still carry unnecessary whole-tree cost; style cascade and projection are the most directly measured bottlenecks. +- Pure render and mounted update are both affected by retained coupling but are not the same problem: pure render should bypass retained publication, while mounted update must preserve identity/rollback and narrow its computation closure. +- Optimizing `ecss-compute-style`, `tp-bind`, `ensure-node-tree`, or one helper in isolation will not change the fact that whole-tree work is scheduled on every path. +- Removing the duplicated candidate handoff improves the lifecycle boundary, and the partial-row slice demonstrates both affected layout/projection closure and proof-driven TP subtree reuse; the general mounted scope proof is still incomplete because viewport and broader structural paths remain wider. +- TP publication remains the necessary boundary for correctness, identity, revision, and rollback. The evidence supports delaying and narrowing entry into that boundary, not removing TP. Final publication is not the dominant observable bottleneck, but the independent contribution of retained TP construction remains unknown. + +## High-level redesign direction + +The target data flow should have two explicit ownership paths: + +```text +Pure materialization +Source Tree + -> style snapshot + -> layout + -> fragments + -> propertized string + +Mounted update +State mutation + -> affected owner closure + -> selective style/layout/paint + -> reuse retained fragments/ranges + -> TP publication +``` + +### 1. Separate pure materialization from retained publication + +When the caller only needs a render string, `ebox-render` should not create a TP identity graph, reactive bindings, owner publication graph, or rollback participant. Only a mounted surface that needs stable identity, scope proof, revision, or rollback should enter the retained-publication boundary. + +The split should occur at the publication boundary, after the single layout/fragment implementation, not before it. Pure and retained paths must share style snapshots, layout, and fragment generation to avoid two rendering semantics. If a product flow renders a preview and later mounts it, the promotion of ephemeral state to stable identity must be explicit, or a full attach must be accepted. + +### 2. Make style/cascade a snapshot rather than default per-node reactive binding + +Static inline style should enter a computed-style snapshot directly. Only nodes that truly depend on external dynamic values should install reactive bindings. Inherited-style invalidation should follow ancestor/subtree dependencies instead of making the entire tree recalculate whenever a surface is cascade-required. + +Formal implementation requires a dependency-correctness gate first: for stylesheet and inline-inheritance triggers separately, record the actually read style inputs and compare the predicted affected set with a full recomputation, checking both under-invalidation and over-invalidation. Snapshotting alone does not create locality; a dependency graph that remains conservatively whole-tree will erase the benefit. + +### 3. Use one ownership representation + +The layout stage should produce a structured fragment/owner map, with unchanged fragments, ranges, and property state reusable. A local patch should not rescan the complete rendered string or reconstruct owner ranges, shared leaves, and the property ledger for unchanged nodes. + +### 4. Use copy-on-write candidates + +The fixed-footprint mounted content slice now implements this policy for one proven string replacement shape: it copies only the affected owner path and passes the sharing decision explicitly to the surface. Ordinary content/paint updates should follow this pattern only after the same proof exists. Structural, geometric, identity, or genuinely expanded rollback/scope-proof changes may construct a larger candidate. Rollback should remain at the publication boundary instead of making every update pay the full-tree isolation cost in advance. + +The scoped candidate closure cannot be equated with a syntactic owner subtree; text growth, flex siblings, percentages, and container constraints can enlarge the layout-context closure. Copy-on-write also needs an explicit rollback depth and old-root lifetime policy so long editing sessions do not retain an unbounded number of generations. + +### 5. Keep TP’s correctness responsibilities but narrow its entry condition + +TP should continue to own live-buffer publication, diffing, revisions, stable retained identity, and rollback. The architectural repair is not to bypass TP; it is to keep pure render out of TP and give mounted updates only the affected structure and ownership. + +## Problem separation and recommended order + +| Stage | Problem to solve | Problem not to merge into it | +| --- | --- | --- | +| Pure render | String materialization should not create retained identity, bindings, or a publication graph | Mounted-update rollback and stable identity | +| Mounted content update | A dirty owner should not expand its computation closure to the whole tree | Viewport damage’s special geometric dependencies | +| Style cascade | Recompute only the actual affected style-dependency closure | TP property-publication tuning | +| Viewport resize | Establish a viewport damage region and affected layout closure | Treating legacy’s 51 nodes as the correct answer by default | +| Candidate/ownership | Reuse unchanged state after dependency closure is stable | Assuming every ownership structure is duplicated before timing it | + +Recommended order: + +1. P0a: Keep the evaluator and stage probes as the contract. The current probe confirms text-property-level equality and non-overlapping aggregate stages; candidate preparation is now separately measured, and the isolated handoff slice proves duplicate copy/validation/reconciliation can be removed without changing publication semantics. +2. P0b: Establish a style-dependency correctness gate covering stylesheet and inline inheritance separately; do not formally narrow style invalidation before the contract passes. +3. P0c: Done for the no-stylesheet/no-inline-inheritance slice: isolated pure materialization is implemented and passes the evaluator. Keep stylesheet-required pure rendering as a separate unresolved path until style snapshot dependencies are proven. +4. P0d: Run the full 2×2 experiment after the dependency gate and pure spike produce results, then decide the formal order of style snapshots, retained-path slimming, and scoped projection. +5. P1: Generalize the implemented partial-line scoped projection, fixed-footprint COW region candidate, strict retained viewport reflow, affected-layout closure, and proof-driven TP subtree reuse to stable content, flex, overflow, cascade, and rollback-sensitive paths. The fixed-footprint evaluator records node-projection `1`, layout/fragment/ownership `4`, and TP-object-preparation `5`; the strict viewport fixture records node-projection `1`, TP-object-preparation `5`, and TP `reconciled-objects=4`, while broader fallback paths remain unmeasured. +6. P1: Establish an independent damage region and affected layout closure for viewport resize beyond the strict proof subset, and reverse-engineer legacy’s local dirty/layout closure as a reference. +7. P2: Extend COW and ownership reuse beyond the fixed-footprint proof only after dependency, identity, scope-proof, and rollback contracts are stable, with an explicit rollback-depth/memory policy. +8. P2: Evaluate TP property operations last; do not make them the primary target while pre-publication costs dominate. + +## Minimal discriminating experiment: 2×2 root-cause separation + +This is the most valuable experiment before formal implementation, but it requires the read-only pure-materialization spike from P0c first. The two factors are retained-path usage and style-computation mode: + +| | Reactive style | Style snapshot | +| --- | --- | --- | +| Retained path on | Current mounted baseline | Replace only style computation | +| Retained path off | Pure materialization baseline | Pure materialization plus snapshot | + +Run the matrix on `dynamic-update-reference.ebox` and the 300-box stylesheet A/B fixture. Record total time, style calls, `ensure-node-tree` calls, reconciled objects, TP identity/retain counts, owner-range time, text/property operations, output length, and SHA-256. + +- If disabling the retained path sharply lowers pure-render time, the `ebox-render` boundary is an important cause. +- If style calls remain 1262 with the retained path disabled, style cascade is an independent cause. +- If snapshots sharply lower style calls and total time, cascade invalidation is one of the main causes. +- If both changes still leave a large gap to legacy, continue splitting projection, candidate, ownership, and layout. + +“Retained path off” is valid for pure materialization or a read-only experiment only; mounted updates must not remove stable identity, rollback, or scope proof. + +The minimal execution sequence is: complete stage breakdown first; use read-tracking to validate stylesheet and inline-inheritance dependency closures; then run retained on/off with reactive style fixed; only after the dependency gate passes run the full four-cell matrix. This avoids a circular dependency between experiment infrastructure and formal redesign. + +## Verification boundary for the target architecture + +Before implementing the redesign, the target architecture should demonstrate these observable properties: + +- Pure `ebox-render` does not create TP retained identity, surface bindings, or a publication graph. +- A content/paint update with no structural, geometric, or style-dependency change does not reproject the whole tree. +- An active stylesheet does not automatically imply that every node must recalculate; only affected cascade dependencies are invalidated. +- Local-update `ensure-node-tree`, reconciled-object, and style-call counts vary with the affected closure rather than remaining fixed at whole-tree scale. +- The computation scope of `owner-rerender`, `span-patch`, and `paint-patch` no longer expands to the whole tree by default. +- Structural changes, viewport geometry changes, and rollback-sensitive updates can still widen scope while preserving stable identity, TP transactions, and failure rollback. +- A retained `viewport-reflow` is selected only after topology, cascade, scroll, overflow, and owner-scope proofs pass; all other viewport changes use the ordinary correctness path. +- Scoped style must be equivalent to full recomputation for computed-style results (including inputs relevant to text properties) and must cover both stylesheet and inline-inheritance triggers. +- Scoped candidates must have an explicit affected-closure proof, old-root rollback semantics, and a bounded rollback/memory-retention policy. + +## Unknowns and limits + +- Legacy has no equivalent ECSS stylesheet/reactive cascade, so the full timing difference cannot yet be attributed one-to-one across cascade, layout, and ownership. +- Scroll offset, paint-only update, and batch update have not yet received equivalent stage breakdowns; the current conclusion is supported mainly by content update, viewport resize, pure render, and stylesheet A/B measurements. +- Which ownership/index structures can be merged without breaking stable identity, rollback, and TP scope proof requires follow-up read-only probes and contract tests. +- Style snapshots must cover dependency closures for theme, ancestors, selectors, viewport, faces, and other dynamic inputs; otherwise performance may be bought with incorrect rendering. +- Copy-on-write candidates depend on stable identity not relying on object addresses, immutable retained state before commit, and an old root/ledger that remains available for rollback. +- Content-update closure may include layout context and sibling nodes, not only the dirty owner subtree; legacy dirty propagation should be reverse-engineered as reference evidence. +- It is not yet known whether scope proof is check-driven or construction-driven. If whole-tree candidates currently provide safety by construction, scoped/COW candidates need an explicit proof mechanism. +- Absolute timings depend on local Emacs, display, and power state. This record uses relative stage structure and identical-output checks as the primary evidence. + +## 2026-08-09 flex resize checkpoint + +The exact public GUI evaluator for `ebox-playground/examples/flex-reference.ebox` was rerun after reverting an experimental viewport-dependent generic-cache override. The single `window-resize` action changed the preview viewport from `256` to `186` pixels and produced `1.202837s` end to end (`0.748485s` inside `ebox-rerender-buffer-with-context`). Preview mount, exactly one update, scoped publication, retained `viewport-reflow-mixed-scroll` (`reconciled=4`, `full-root=nil`, `scope-fallback=nil`), fresh-render visual/text-property equivalence, and the canvas background check all passed. The declared `0.5s` gate failed. This is the current acceptance result; it is not a millisecond-scale result. + +The same-path stage probe is directional because advice adds measurement overhead, but it identifies the cost center: candidate rendering/materialization was about `0.38s` in the cleanest sample, while the root path made `142` `ebox--render-layout` calls. The probe saw `80` generic cache probes with only `2` hits and `78` no-context cases; fragment retention saw `8` hits, `37` rerenders, and `15` stores. TP snapshot/render/publication stages were not the dominant independent cost in that sample (`tp-text-snapshot` about `0.06s`); TP remains the required identity/publication/rollback boundary. + +The Sol xhigh architecture review rejects widening generic viewport cacheability or changing the existing fragment key as a safe fix: retention lookup happens after child measurement and Flex reflow, and a hit cannot skip the parent reflow or final owner/range materialization. The owning boundary for the next optimization is therefore the structured `layout -> fragment/owner result` handoff. A reusable result must carry dimensions, owner/property runs, and its local containing-block/display signature; `ebox-surface` can then consume those runs directly without rescanning the complete propertized string. That larger slice must preserve candidate COW, rollback, scoped publication, and exact fresh-render equivalence. + +Focused current checks remain useful but do not close the gate: Ebox Flex is `62/62`, Ebox surface is `49/49`, and the exact evaluator's correctness checks pass. The broader core run has two known failures in the already-dirty worktree (shrunk definite-item slot count and underfilled chrome mapline count), so they are recorded as regression gaps rather than silently attributed to this checkpoint. No temporary probe daemon or production cache override remains. + +## Record boundary + +This document fixes the root-cause model and high-level data-flow direction. It records five verified, narrow implementation slices but does not authorize benchmark-specific branches, timing hacks, relaxed performance gates, or fixture-specific cache paths. The remaining ownership and publication boundaries must be converted into verifiable design contracts and changed incrementally. diff --git a/docs/maintainer/ebox-performance-architecture-analysis.zh.md b/docs/maintainer/ebox-performance-architecture-analysis.zh.md new file mode 100644 index 0000000..4a2fe2d --- /dev/null +++ b/docs/maintainer/ebox-performance-architecture-analysis.zh.md @@ -0,0 +1,361 @@ +# Ebox 性能架构诊断与重构方向 + +> 状态:架构诊断与实现 checkpoint。本文记录 current refactored Ebox 相比历史 `emacs-box` 的性能差异、证据边界和高层重构方向。五个范围很窄的 slice 已经实现;mounted retained-update 架构尚不能认为已经普遍修复。 +> +> 日期:2026-08-07 +> +> 修订:2026-08-08。根据独立架构审查,本文将 style cascade、projection、candidate、pure render 和 retained TP coupling 拆为不同问题,降低未被阶段计时直接证明的结论置信度,并记录隔离 pure materialization、candidate handoff、partial-line scoped projection、严格 retained viewport-reflow 和 fixed-footprint copy-on-write 五个 checkpoint。 + +## 问题 + +为什么 current refactored Ebox 在动态内容更新、viewport resize 和纯 `ebox-render` 场景下,性能达不到重构前的 `emacs-box`?需要先确定数据流和 ownership 层面的主因,而不是继续优化某个具体函数。 + +## 结论 + +性能回退的主因不是某个函数单独变慢,而是在 baseline retained 数据流中,computation scope 被错误地绑定到一条全树路径:current Ebox 把“生成最终带文本属性的字符串”和“维护一个 retained、可增量更新、带稳定 identity 的 TP surface”连接在同一条路径上,并在尚未收窄 scope 的路径中触发全树 style cascade、projection 和 candidate preparation。 + +因此,在 scoped slice 之前,即使一次更新最终只需要一个 owner、一个 text operation,current 仍然会先支付全树 style computation、node projection、candidate preparation 和 ownership/range 构造的成本。修复前的路径还会重复 identity reconciliation。partial-row checkpoint 已经移除了一个稳定 owner 形状上不必要的 projection/layout 扩张,但未解决的路径仍可能扩大;现有阶段数据直接证明 style cascade 和 baseline projection 是主要可测成本,但还没有单独证明 TP identity、retain 或最终 publication 本身是最大瓶颈。 + +关键判断是:current 的“局部 patch”只是 publication 粒度,不是 computation 粒度。legacy 的局部更新在大多数情况下同时是局部计算和局部 publication;current 只在最后一步变成局部。Retained TP boundary 应被视为造成这种 scope coupling 的架构边界,而不是未经拆分计时证明的单一底层瓶颈。现在已经为一个可测场景建立严格的 retained `viewport-reflow` slice:复用 retained node-object subtree、重新执行 viewport layout,并把 cascade、inheritance、scroll、visible-overflow 和 topology 场景拒绝到普通安全路径。 + +## 比较范围与术语 + +- “current”指独立重构版 `ebox` 仓库。 +- “legacy”指同级历史 `emacs-box` 源码树。 +- “pure materialization”指从 source tree 产生带文本属性的渲染字符串,不需要 live buffer、稳定 retained identity 或 rollback。 +- “retained publication”指向已挂载 TP surface 发布候选状态,包含稳定 identity、ownership、scope proof、revision 和 rollback 语义。 +- 文中的绝对耗时来自同一个本地测量环境;架构判断依赖阶段占比、调用次数和相同输出结果,不依赖某一个绝对时间阈值。 + +## 阶段级证据 + +### 动态 content update + +使用同一个约 1712 节点的 `dynamic-update-reference.ebox` fixture,current parser 能构造相同规模的节点树。该 fixture 没有 stylesheet rule,但存在 inline inherited style。 + +| 项目 | Current | Legacy | 直接观察到的事实 | +| --- | ---: | ---: | --- | +| 总耗时 | 12.127969s | 0.073844s | 输出长度为 1177,SHA-256 完全一致 | +| projection 起始阶段 | 10.882838s | 不存在 | current 调用 `ensure-node-tree` 1712 次;区间边界仍需在探针合同中定义 | +| style computation | 1262 次,约 8.99s | 无等价阶段 | `ecss-compute-style` 1262 次,约 8.95s | +| 最终文本操作 | 1 次 | 局部 patch | current report 仍是 `span-patch`,dirty 只有 1 个 owner | +| TP 结构统计 | reconciled objects 1716;property operations 378 | 直接 buffer/region 路径 | TP 最终 publication 不是主要耗时来源 | + +current 在完成全树 projection 和 style computation 后,最终只需要一个局部 owner 的 patch;legacy 只格式化目标内容并执行局部 patch。这排除了输出文本长度和当前探针记录的文本 SHA-256 差异作为主要解释,但 SHA-256 是否包含完整 text properties 仍需确认,不能把它直接当作 `equal-including-properties` 证明。 + +### Viewport resize + +对同一 fixture 做 `900 → 720` resize: + +- current 总耗时为 `12.447619s`,projection 起始阶段为 `11.305584s`,仍然调用 `ensure-node-tree` 1712 次并计算 1262 个 style。 +- current 的 TP 统计只有 1 个 text operation、361 个 property operations,Ebox report 是 `owner-rerender`,dirty 为 1、scope 为 1。 +- legacy 总耗时为 `0.404387s`,只渲染约 51 个局部节点;输出长度和 SHA-256 与 current 一致。 + +所以 `owner-rerender` 这个名称描述了最终 publication scope,不能证明前面的 layout、style、candidate 和 projection 也只处理了该 owner。严格 retained viewport checkpoint 在 evaluator fixture 上测得 `node-projection=2`、`TP-object-preparation=5` 和 `reconciled-objects=4`;这只是已经证明安全的子集,不代表所有 resize 路径都具有同样的 closure。 + +### 纯 `ebox-render` + +在同一个 full fixture 上: + +- current 总耗时为 `12.420715s`,其中 `ebox-surface--project` 为 `12.293075s`,projection 起始阶段为 `11.556190s`,`ecss-compute-style` 调用 1262 次、约 `9.34s`。 +- current 的 `tp-surface-materialize-string` 本身几乎覆盖了整个总耗时,因为它调用了 retained surface 的构造路径。 +- legacy 总耗时为 `0.710261s`,直接执行 layout/render,不建立等价的 TP surface projection。 +- 两套实现的输出长度为 1183,SHA-256 完全一致。 + +这说明 current 的 `ebox-render` 虽然对调用者表现为“返回字符串”,内部却进入了 retained surface 构造路径。它证明了边界耦合存在,但不能仅凭聚合阶段证明 retained identity、binding 或 ownership graph 各自的独立成本。 + +### Stylesheet A/B + +在一个没有 inline inheritance 的 300-box 树上: + +| 条件 | 总耗时 | Ebox style calls | ECSS calls | 输出 | +| --- | ---: | ---: | ---: | --- | +| 无 stylesheet | 0.481574s | 0 | 0 | 与另一组长度和 SHA-256 一致 | +| 添加一条 `box` rule | 2.973969s | 301 | 301 | 与另一组长度和 SHA-256 一致 | + +一条没有改变最终视觉输出的 stylesheet rule,就足以把静态树切换到 per-node cascade 计算路径。这表明 stylesheet active 是当前 style computation 扩散的重要开关,而不是只有实际样式变化时才付费。 + +动态 fixture 的触发条件是 inline inherited style,A/B fixture 的触发条件是 stylesheet rule;两者不是同一条触发路径。文档目前证明了两条路径都会进入 style computation,但还没有证明对两条路径使用同一种 snapshot/invalidation 修复就能得到相同收益。 + +当前三个 current 场景的总耗时为 `12.127969s`、`12.447619s` 和 `12.420715s`,变化小于 3%;legacy 对应为 `0.073844s`、`0.404387s` 和 `0.710261s`。这支持“current 存在与操作类型弱相关、与树规模强相关的固定全树成本”,但不能单独指出该成本来自 style、projection、candidate 还是 retained 构造。 + +## 已实现 checkpoint:隔离静态 materialization + +第一个可逆的架构 slice 已经在一个窄场景下实现:source 没有 active stylesheet,且不需要 inline inherited-style 计算。此时 `ebox-render` 创建隔离的 candidate 和 render-side tables,然后直接运行 raw layout/materialization 路径,不再构造 retained TP object tree。需要 stylesheet 的 pure render,以及所有 mounted 路径,仍然使用 retained surface 路径。 + +声明的 evaluator 同时确认了优化结果和边界: + +| 场景 | 耗时 | 相关阶段证据 | 正确性证据 | +| --- | ---: | --- | --- | +| Pure static | `0.047611s` | 没有 node-projection、TP-object-preparation 或 publication 阶段 | 隔离输出通过 `equal-including-properties` 与 TP-backed reference 相等 | +| Stylesheet pure | `0.171233s` | cascade 被执行;`ecss-compute-style` 调用 21 次 | TP-backed 输出等价性保持 | +| Mounted content update | `0.061124s` | node-projection 3 次,TP-object-preparation 5 次 | mounted identity 保持;`full-root=nil`,scope count 为 `2`;TP reconciles 4 个 object | +| Viewport resize | `0.044602s` | node-projection 28 次,TP-object-preparation 58 次 | root identity 保持;`full-root=nil`,scope count 为 `1`;TP reconciles 30 个 object | + +同一个 evaluator 的较早对照是:slice 前 `0.037040s`,slice 后 `0.025286s`,约下降 32%。上表的新鲜数值是环境采样,不能替代相对 checkpoint。这个 slice 证明 pure static boundary 可以移除 retained projection 成本而不改变输出;它不能证明 mounted scoped update 的计算已经局部化。candidate handoff 边界已经在下节单独处理,但 projection 和 layout 仍需要继续收窄。 + +## 已实现 checkpoint:隔离 candidate handoff + +Region 和 scroll update 现在会把已经隔离的 runtime candidate 继续传过 incremental planner 和 surface producer。这个内部 handoff 会跳过第二次 structural copy、declarative-root validation 和 runtime-identity reconciliation;viewport update 刻意不使用该标志,因为它从已发布 root 开始,仍需要普通 candidate 策略。 + +最新 evaluator 和 focused regression 的证据如下: + +| 场景 | Candidate 证据 | 仍然存在的计算范围 | 正确性证据 | +| --- | --- | --- | --- | +| Mounted content update | 只进行一次 candidate structural copy;没有重复 validation 或 reconciliation | `ensure-node-tree` 0 次,node-projection 3 次,layout/fragment/ownership 4 次,TP-object-preparation 5 次 | surface tests 32/32;commit tests 4/4;带属性输出精确相等;mounted identity 保持;`full-root=nil`,scope count 为 `2`;TP reconciles 4 个 object | +| Scroll update | 隔离 candidate handoff 被接受,TP 合同未改变 | 尚无独立 scroll 阶段拆分 | surface regression 通过;TP revision/publication 行为保持有效 | +| Viewport resize | 已发布 root 的普通 candidate 路径保持不变 | `ensure-node-tree` 26 次,layout/fragment/ownership 7 次,TP-object-preparation 58 次 | 输出精确相等;root identity 保持;`full-root=nil`,scope count 为 `1`;TP reconciles 30 个 object | + +这个 checkpoint 消除了重复的 candidate 生命周期。下面的 partial-line slice 又把一个稳定 owner 形状的 projection 和 layout 收窄了;更宽的 mounted 路径仍可能在最终 publication scope 之外建立 index、执行 projection、layout 和 ownership 构造。下一步实现必须推广 affected layout/projection closure,而不是回到 candidate helper 微优化。 + +## 已实现 checkpoint:partial-line scoped projection + +Mounted span-patch 现在把 partial-line owner 视为一组已有的 parent slots,而不再要求该 owner 的 spans 连续。每个 slot 独立塑形;whole-line spans 仍保留 contiguous-span 安全证明。如果 span proof 失败,TP fallback 会复用已经准备好的 surface root、node root 和 object table,不再构造第二个 surface context。Paint-only update 会先确认 paint patch,再进行 span proof 和文本测量,因此仍留在 paint 路径。 + +span proof 成功后,mounted producer 会对已经证明不变的 node-object subtree 使用 TP 的 `tp-object-reuse-subtree`。candidate 仍携带完整 output plan 和 content-range attachments,但不变 subtree 不再进入 TP 的 touched/reconcile 集合。这不是通用的省略:如果 candidate topology、output footprint、parent slots、roles 或 overflow signature 变化,仍会回退到普通 projection/reconciliation 路径。 + +最新 evaluator 和 focused regression 的证据如下: + +| 场景 | 范围证据 | 正确性证据 | +| --- | --- | --- | +| Mounted partial-row content update | `ensure-node-tree=0`,node-projection `3`,layout/fragment/ownership `4`,TP-object-preparation `5` | 耗时 `0.061124s`;`equal-including-properties`;mounted identity 保持;`full-root=nil`、scope count 为 `2`;策略为 `span-patch` | +| Mounted partial-row 路径的 TP retained bookkeeping | `reconciled-objects=4`,`created-objects=0`,`removed-objects=0` | 已证明不变的 subtree 被保留且不进入通用 reconciliation;scoped TP transaction 仍有效;没有 full-root fallback | +| Viewport resize 路径的 TP retained bookkeeping | `reconciled-objects=30`,`created-objects=0`,`removed-objects=0` | 普通 owner-rerender 路径正确且 publication 仍 scoped,但 retained closure 尚未收窄 | + +这证明对于一个 partial-row update,affected-owner projection/layout 边界和 retained-object bookkeeping 都可以局部化。但它不证明所有 structural、flex、overflow、viewport 或 rollback-sensitive update 都能使用同样的 closure:当前 viewport owner-rerender 路径仍然 reconciles 30 个 objects。 + +## 已实现 checkpoint:严格 retained viewport reflow + +Viewport change 现在有独立的 retained projection kind:`viewport-reflow`,但只在一个刻意收窄的 proof boundary 内启用。candidate 保留已发布 root,并复用 retained TP node-object table;producer 重新执行完整的 viewport-sensitive layout 和 surface plan,不调用 `ensure-node-tree`,也不把该操作伪装成 `span-patch`。之后仍由 TP 通过 retained root scope 发布变化后的输出。 + +Planner 只有在以下条件全部满足时才授权该路径:owner plan 是一个 root-owned geometry rerender;没有 dirty entry 修改 children;root、node-key set、region-id set 和 parent table 不变;旧 runtime 和当前 runtime 都不需要 ECSS cascade;没有 scroll state;整棵树没有 visible overflow;并且旧 surface 存在 retained node-object table。height-only 与 width-plus-height 变化使用同一套 proof。active stylesheet rule、inline inheritance、scroll state、visible overflow 和 publication failure 都有明确 regression case;这些情况会回退,或通过普通 TP transaction 回滚。 + +当前 evaluator 与 focused tests 给出的 checkpoint 如下: + +| 场景 | 范围证据 | 正确性证据 | +| --- | --- | --- | +| evaluator fixture 的 viewport resize | `node-projection=2`、`layout/fragment/ownership=7`、`TP-object-preparation=5`、`ensure-node-tree=0`、`reconciled-objects=4` | 耗时 `0.080036s`;runtime output 精确相等;root identity 保持;`full-root=nil`、scope count 为 `1`;1 个 text operation | +| Height-only 与 both-axis resize | 使用 retained `viewport-reflow`;没有 node-tree ensure | `:viewport-axes` 分别为 `height` 和 `both`;输出宽度及 publication 正确 | +| 不安全 viewport 场景 | active stylesheet、inline inheritance、scroll 和 visible overflow 使用普通 projection | surface suite `39/39`;commit suite `4/4`;没有不安全场景被误标为 `viewport-reflow` | +| Viewport publication 失败 | 在 candidate preparation 后注入 TP publication failure | surface revision、signal values、client state、Ebox state 和 buffer text 都保持旧 generation | + +这关闭了 evaluator fixture 上此前测得的 viewport 过度计算,但只在上述 proof boundary 内成立。更广的架构目标仍是为 flex、scroll、cascade、overflow 和 structural 场景建立 dependency-driven viewport damage closure,而不是凭猜测扩大这个 fast path。 + +## 已实现 checkpoint:fixed-footprint copy-on-write region candidate + +Mounted content-only update 现在增加了一条刻意收窄的 candidate 路径。当更新是在固定 numeric width、没有 content child node、没有 active cascade、没有 scroll state 的 box 上替换字符串内容时,incremental layer 只复制 region owner 及其 ancestor path。Retained runtime table 通过 local index delta 准备,surface 收到显式的 path-copied 标记。这样 candidate preparation 仍由 Ebox 的 planning layer 负责,而 TP 继续拥有 mounted identity、scoped publication、revision 和 rollback。 + +这条路径只有在既有 span-patch identity/footprint proof 以及针对格式化替换内容的 line-count preflight 都通过后才会进入 surface。多行或可能 wrapping 的替换会在 surface projection 之前升级为普通 private structural copy。因此 surface 不会清除共享的 published node attachment,也不负责 emergency fallback copy。 + +| 场景 | 范围证据 | 正确性证据 | +| --- | --- | --- | +| Fixed-footprint mounted content update | 没有完整 `ebox--runtime-index`;evaluator 报告 node-projection `1`、layout/fragment/ownership `4`、TP-object-preparation `5`;focused regression 证明没有第二次完整 structural copy | `equal-including-properties`;target mounted identity 保持;`full-root=nil`、scope count 为 `2`;策略为 `span-patch`;evaluator 与 commit suite 通过 | +| 多行或扩宽替换 | line-count preflight 拒绝 path sharing,并在进入 surface projection 前升级为 private candidate | rollback regression 观察到 private copy,注入 publication failure 后旧 Ebox state、output 和 published content 均保持不变 | + +这是 proof-bounded COW slice,不是通用 candidate policy。Structural、flex、overflow、cascade、scroll、viewport 以及其他会改变 footprint 的 update,在各自的 affected closure 和 rollback depth 被单独证明前,继续使用普通正确性路径。 + +## 当前验证边界 + +声明的 evaluator 在 2026-08-08 通过:`make performance-evaluator` 运行 surface tests 39/39、commit tests 4/4,四个场景的输出、identity、scoped publication 和不重叠阶段检查全部通过。Fixed-footprint mounted 场景保持了 `equal-including-properties`,保留 target object,并以 `full-root=nil`、scope count `2` 发布;viewport 场景以 `full-root=nil`、scope count `1` 和 `reconciled-objects=4` 发布。 + +较晚 COW checkpoint 之前的更宽 `make check` baseline 已通过:core 457/457、grid 15、commit 4、surface 32、visual-check 4、package 11、selector 22、DSL 25、flex 60、文档合约 4、CI 合约 4。当前 focused surface suite 为 39/39,覆盖 fixed-footprint COW fallback、height/both-axis 和 rollback。此前的回归已经关闭;performance goal 仍不能完成,因为更广的 viewport 和 mounted affected-closure proof 尚未完成。 + +## 已解决的验证回归 + +完整测试暴露的是两个生命周期边界错误,而不是测试期望值错误。第一,`ebox--render-ephemeral-static` 曾在函数内部把 `ebox--defer-scroll-content-index` 强制绑定为 nil,覆盖调用者的 deferred-scroll 合同并额外产生 sentinel/lookahead 行;现在已移除该局部覆盖,由调用者拥有这个模式。第二,`ebox-incremental-surface-batch-root` 在 batch flush 后仍返回最后一个 batch candidate,后续非 batch update 因而原地修改已发布 root;old/new box 发生别名,declarative dirty set 变空,scoped publication 最终拿不到 retained owner。现在只有 active batch 才能通过该 accessor 取得 candidate。两个回归都已通过,且没有增加 scope fallback 或改变既有行为期望。 + +## 测量口径与等价性限制 + +- `projection-start` 的起点和终点尚未定义;目前不能安全地用 projection 总时间减去 style 时间来计算“非 style projection 成本”。后续探针必须明确 candidate copy、style、layout、fragment、owner range、ledger、identity reconcile、retain 和 publication 各自的边界。 +- `1712` 个节点、`1716` 个 reconciled objects、`1262` 个 style calls 和 300-box fixture 的 `301` 个 calls 的计数口径尚未解释;需要确认 root、wrapper、text leaf、跳过节点、缓存命中等是否包含在内。 +- 较早的 partial-row checkpoint 报告 `ensure-node-tree=0`、node-projection `3`、layout/fragment/ownership `4` 和 TP `reconciled-objects=4`;当前 fixed-footprint mounted evaluator 报告 node-projection `1`、layout/fragment/ownership `4` 和 TP-object-preparation `5`,严格 retained viewport checkpoint 也报告 node-projection `1`、TP-object-preparation `5` 和 `reconciled-objects=4`。更广的 fallback 路径仍可能报告较旧的全树计数,因此必须把 Ebox projection scope 和 TP retained bookkeeping 分开测量。 +- 历史 baseline probe 只记录了长度和 SHA-256,没有证明完整 text properties。当前 evaluator 已对 isolated 与 mounted 比较使用 `equal-including-properties`;后续 scope 变化必须保留这个断言。 + +## 根因排序 + +| 排名 | 根因 | 置信度 | 证据基础 | +| --- | --- | --- | --- | +| 1 | ECSS/reactive style cascade 按节点 eager recomputation | 高 | 动态更新中 `ecss-compute-style` 1262 次、约 8.99s;纯 render 约 9.34s;一条无视觉影响的 rule 触发 301 次 ECSS computation | +| 2 | Projection/`ensure-node-tree` 经常宽于 publication scope | 高 | 基线探针显示 projection 起始区间为 10.88s、11.31s、11.56s,并调用 `ensure-node-tree` 1712 次;partial-row 和严格 viewport checkpoint 已证明两个安全 closure 可以达到 `ensure-node-tree=0`,更广 fallback 路径仍未解决 | +| 3 | `ebox-render` 抽象边界错误 | 高 | [ebox-layout.el:1444](../../ebox-layout.el#L1444) 默认通过 `tp-surface-materialize-string`;纯字符串调用者仍支付 retained surface projection 成本 | +| 4 | 已证明路径之外的 candidate copy/index preparation 仍然宽于 publication scope | 中 | region/scroll 的重复 handoff 已移除,fixed-footprint region path 避免了完整 runtime index;更广的 content、structural 和 viewport 路径仍需要各自的 closure proof | +| 5 | Retained TP boundary 将 pure render、style、projection 和 mounted update 耦合到共同路径 | 中高 | 纯 render 进入 retained path 是事实;但现有数据不能把 coupling 等同于 TP identity/retain/publication 本身的最大耗时 | +| 6 | Ownership 被多层表示并重复扫描 | 中低 | fragments、owner ranges、TP plan、property ledger 和 text properties 都存在,但没有 ownership 构造/扫描的独立耗时证据 | +| 7 | 原始 layout 算法全面变慢 | 低 | 没有 stylesheet 的部分 flex fixture 中,current 首次 mount 约 1.4–1.5s,legacy 约 1.61–1.63s;不能支持“全面变慢” | + +## 不能从现有证据推出的结论 + +- `ebox-surface--project` 和 `tp-surface-materialize-string` 是聚合阶段;它们覆盖 style、projection、fragment、ownership 和 TP 相关工作,不能直接当作 TP intrinsic cost。 +- `property operations` 的数量(content update 为 378、resize 为 361)不是耗时;必须补充对应阶段计时后才能判断其性能贡献。 +- legacy 只处理约 51 个 resize 节点,说明 current 的 scope 可能过大,但不能单独证明 51 是正确的最小受影响闭包。 +- ownership 多层结构是值得验证的设计风险,但当前没有证明它们都被重复扫描,也没有证明它们可以安全合并。 +- full-tree candidate/index preparation 可能是保守的正确性策略;只有建立 dependency closure、identity 和 rollback 证据后,才能确定哪些剩余全树工作是多余的。隔离 region 和 scroll candidate 的重复 copy/validation/reconciliation handoff 已经移除。 + +## 代码路径证据 + +current 的 retained 数据流由以下边界组成: + +- [ebox-surface.el:535](../../ebox-surface.el#L535) 为整棵树建立 TP projection。 +- [ebox-surface.el:712](../../ebox-surface.el#L712) 对 candidate root 做结构复制,[ebox-surface.el:725](../../ebox-surface.el#L725) 做 identity reconciliation。 +- [ebox-surface.el:760](../../ebox-surface.el#L760) 为节点安装 style binding,[ebox-surface.el:775](../../ebox-surface.el#L775) 读取/应用 computed style。 +- [ebox-surface.el:785](../../ebox-surface.el#L785) 递归执行 `tp-object-ensure` 和 `tp-object-retain`。 +- [ebox-surface.el:1236](../../ebox-surface.el#L1236) 从 fragments 构造并 attach owner ranges。 +- [ebox-surface.el:1269](../../ebox-surface.el#L1269) 把 fragments 转为 shared text leaf 和 TP surface plan。 +- [ebox-surface.el:1627](../../ebox-surface.el#L1627) 根据 stylesheet active、inline inheritance 和 previous cascade state 决定是否进入 style-required 路径。 +- [ebox-incremental.el:5235](../../ebox-incremental.el#L5235) 准备 candidate runtime、runtime index 和 dirty set。 +- [ebox-incremental.el:5933](../../ebox-incremental.el#L5933) 的 scoped commit 有 isolated-candidate 分支复用已准备的 root,剩余 owner plan 决定下游 projection scope。 +- [ebox.el:3486](../../ebox.el#L3486) 的 region update 最终通过 surface-scoped TP publication 发布。 +- [ebox.el:3694](../../ebox.el#L3694) 的 buffer render 进入 surface mount;[ebox-surface.el:381](../../ebox-surface.el#L381) 创建 signals、producer 和 TP transaction。 + +legacy 的对应路径更接近“计算范围等于修改范围”: + +- `emacs-box/ebox.el:7195` 直接修改目标 region box,并进入局部 dirty/patch 路径。 +- `emacs-box/ebox.el:7399` 只格式化目标内容后执行局部 patch。 +- `emacs-box/ebox.el:7578` 直接 render、insert 并刷新 marker/span。 +- legacy 的 `ebox-style.el` 主要做本地属性展开和 dirty 分类,没有 current 的 ECSS stylesheet/reactive per-node cascade。 + +## Evidence 与 Inference 的边界 + +### Evidence + +- current 的 projection、style、candidate 和 ownership 阶段在动态探针中占据绝大多数时间。 +- current 与 legacy 在 content update、resize 和 pure render 的输出长度及 SHA-256 相同。 +- current 的最终 TP text operation 数和 Ebox dirty owner 数很小,但全树前置阶段调用次数很大。 +- stylesheet A/B 在输出不变时仍产生每节点 style/ECSS 调用。 +- current 代码路径确实把 materialization、retained identity、style binding 和 TP publication 连接在同一条调用链上。 +- current 三个场景总耗时近似恒定,而 legacy 随操作类型变化明显;这是全树固定成本存在的旁证,但不是某个内部阶段的独立计时。 +- isolated candidate handoff 回归以一次 structural copy、零次重复 validation/reconciliation 通过;较早 partial-row checkpoint 把 mounted 场景降到 node-projection `3`,fixed-footprint COW checkpoint 现在测得 node-projection `1`、layout/fragment/ownership `4` 和 TP-object-preparation `5`;严格 viewport checkpoint 也测得 node-projection `1`、TP-object-preparation `5` 和 `reconciled-objects=4`。 + +### Inference + +- 性能回退的主要问题是 computation scope 大于 publication scope,而不是最终 TP diff 本身效率不足。 +- 当前 surface 已经 cascade-required 或需要 retained identity 时,静态/局部路径仍然承担了不必要的全树成本;其中 style cascade 和 projection 是目前最直接的测量瓶颈。 +- pure render 和 mounted update 都受 retained coupling 影响,但它们不是同一个问题:前者应绕开 retained publication,后者应保留 identity/rollback 但缩小计算闭包。 +- 只优化 `ecss-compute-style`、`tp-bind`、`ensure-node-tree` 或单个 helper,不会改变全树工作被安排在每条路径上的事实。 +- 移除重复 candidate handoff 改善了生命周期边界,partial-row slice 又证明了 affected layout/projection closure 与 proof-driven TP subtree reuse;但 viewport 和更广 structural 路径仍然更宽,mounted 全面 scope proof 尚未完成。 +- TP publication 仍然是正确性、identity、revision 和 rollback 的必要边界;证据支持的是延迟和缩小进入该边界的工作,而不是删除 TP。最终 publication 不是主要可观察瓶颈,但 TP retained 构造内部各阶段的独立贡献仍属 Unknown。 + +## 高层重构方向 + +目标是把数据流拆成两个有明确 ownership 的路径: + +```text +Pure materialization +Source Tree + -> style snapshot + -> layout + -> fragments + -> propertized string + +Mounted update +State mutation + -> affected owner closure + -> selective style/layout/paint + -> reuse retained fragments/ranges + -> TP publication +``` + +### 1. 分离 pure materialization 与 retained publication + +当调用者只需要渲染字符串时,`ebox-render` 不应创建 TP identity graph、reactive binding、owner publication graph 或 rollback participant。只有 mounted surface 需要稳定 identity、scope proof、revision 或 rollback 时,才进入 retained publication 边界。 + +分离点应位于 publication boundary 之后、fragment/layout 的单一实现之后;pure path 和 retained path 必须共享 style snapshot、layout 和 fragment 生成逻辑,避免为了性能形成两套渲染语义。若存在“先 pure render 预览、后 mount”的流程,mount 时必须明确 identity promotion 或允许一次完整 attach,不能隐式复用 ephemeral object。 + +### 2. 把 style/cascade 变成 snapshot,而不是默认 per-node reactive binding + +静态 inline style 应直接进入 computed-style snapshot。只有真正依赖外部动态值的节点才安装 reactive binding。继承样式失效应由 ancestor/subtree dependency 定位受影响范围,而不是因为某个 surface 进入 cascade-required 状态就让整棵树重新计算。 + +style snapshot 的正式实施必须先通过 dependency correctness gate:对 stylesheet 和 inline inheritance 两条触发路径分别记录真实读取的 style inputs,并比较预测 affected set 与全量重算结果,既检查欠失效也检查过失效。snapshot 化本身不会自动带来局部性;dependency graph 若仍按全树保守建立,收益仍会被全树失效抵消。 + +### 3. 统一 ownership 表示 + +layout 阶段应生成结构化 fragment/owner map,并让 unchanged fragments、ranges 和属性状态可以复用。局部 patch 不应重新扫描完整 rendered string,也不应为未变化节点重新构造 owner ranges、shared leaves 和 property ledger。 + +### 4. Candidate 采用 copy-on-write + +fixed-footprint mounted content slice 已经在一个经过证明的字符串替换形状上实现了这条策略:只复制受影响的 owner path,并把 sharing 决策显式传给 surface。普通 content/paint update 只有在同样的 proof 存在时才能沿用这一模式。结构变化、几何变化、identity 变化或确实需要扩大 rollback/scope proof 时,才构造更大的 candidate。rollback 应保留在 publication boundary,而不是让所有更新提前支付全树隔离成本。 + +scoped candidate 的 closure 不能等同于语法上的 owner 子树;文本变长、flex 兄弟、百分比和容器约束可能使 layout context closure 更大。COW 还需要明确 rollback depth 和旧 root 的生命周期,避免长期编辑会话因保留每一代 root 而产生无界内存增长。 + +### 5. 保留 TP 的正确性职责,但收窄其进入条件 + +TP 继续负责 live buffer publication、diff、revision、stable retained identity 和 rollback。架构修复不是绕过 TP,而是让 pure render 不进入 TP,让 mounted update 只把受影响的结构和 ownership 交给 TP。 + +## 问题拆分与推荐顺序 + +| 阶段 | 应解决的问题 | 不应混入的问题 | +| --- | --- | --- | +| Pure render | 字符串 materialization 不应创建 retained identity、binding 或 publication graph | mounted update 的 rollback 和 stable identity | +| Mounted content update | dirty owner 的 computation closure 不应扩张为全树 | viewport damage 的特殊几何依赖 | +| Style cascade | 只重算真实受影响的 style dependency closure | TP property publication 优化 | +| Viewport resize | 建立 viewport damage region 和 affected layout closure | 把 legacy 的 51 个节点直接当作正确答案 | +| Candidate/ownership | 在 dependency closure 稳定后复用 unchanged state | 尚未计时就假定所有 ownership 结构都重复 | + +建议按以下顺序推进: + +1. P0a:保留 evaluator 和阶段探针作为合同。当前探针已经确认 text-property 级输出等价性和不重叠的聚合阶段;candidate preparation 已单独计时,isolated handoff slice 也证明可以在不改变 publication 语义的情况下移除重复 copy/validation/reconciliation。 +2. P0b:建立 style dependency correctness gate,分别覆盖 stylesheet 和 inline inheritance;在契约测试通过前,不正式缩小 style invalidation。 +3. P0c:无 stylesheet、无 inline-inheritance 的 slice 已完成:隔离 pure materialization 已实现并通过 evaluator。需要 stylesheet 的 pure render 仍应作为独立未解决路径,直到 style snapshot 的依赖合同得到证明。 +4. P0d:在 dependency gate 和 pure spike 有结果后运行完整 2×2 实验,再决定 style snapshot、retained 瘦身和 scoped projection 的正式顺序。 +5. P1:将已经实现的 partial-line scoped projection、fixed-footprint COW region candidate、严格 retained viewport reflow、affected-layout closure 和 proof-driven TP subtree reuse 推广到 stable content、flex、overflow、cascade 及 rollback-sensitive 路径。Fixed-footprint evaluator 记录 node-projection `1`、layout/fragment/ownership `4` 和 TP-object-preparation `5`;严格 viewport fixture 记录 node-projection `1`、TP-object-preparation `5` 和 TP `reconciled-objects=4`,更广 fallback 路径仍待测量。 +6. P1:在严格 proof subset 之外,为 viewport resize 建立独立 damage region 和 affected layout closure,并逆向记录 legacy 的局部 dirty/布局闭包作为参照。 +7. P2:只有在 dependency、identity、scope proof 和 rollback 合同稳定后,才能把 COW 和 ownership reuse 推广到 fixed-footprint proof 之外,并明确 rollback depth/内存策略。 +8. P2:最后评估 TP property operations;不要在 pre-publication 成本未下降前把它作为主攻方向。 + +## 最小判别性实验:2×2 根因分离 + +这是进入正式实现前最有价值的实验,但需要先完成 P0c 的只读 pure-materialization spike。两个因子分别是 retained path 和 style 计算模式: + +| | Reactive style | Style snapshot | +| --- | --- | --- | +| Retained path on | 当前 mounted 基线 | 只替换 style 计算策略 | +| Retained path off | pure materialization 基线 | pure materialization + snapshot | + +在 `dynamic-update-reference.ebox` 和 300-box stylesheet A/B fixture 上,每组记录总耗时、style calls、`ensure-node-tree` 次数、reconciled objects、TP identity/retain 次数、owner range 构造耗时、text/property operations、输出长度和 SHA-256。 + +- 如果关闭 retained path 后 pure render 大幅下降,说明 `ebox-render` 边界错误是重要原因。 +- 如果关闭 retained path 后 style calls 仍为 1262,说明 style cascade 是独立根因。 +- 如果 snapshot 使 style calls 和总耗时显著下降,说明 cascade invalidation 是主因之一。 +- 如果两者都降低后仍远高于 legacy,继续拆分 projection、candidate、ownership 和 layout。 + +该实验中的“关闭 retained path”只适用于 pure materialization 或只读实验路径;mounted update 不能删除 stable identity、rollback 或 scope proof。 + +建议的最小执行序列是:先完成纯测量阶段拆分;再用 read-tracking 验证 stylesheet/inline inheritance 的 dependency closure;然后只切换 reactive style 下的 retained on/off;最后在 dependency gate 通过后运行完整四格矩阵。这样不会把实验基础设施和正式重构互相循环依赖。 + +## 目标架构的验证边界 + +在实现任何重构前,目标架构至少应满足以下可观测性质: + +- pure `ebox-render` 不创建 TP retained identity、surface binding 或 publication graph。 +- 没有结构、几何或样式依赖变化的 content/paint update,不重新投影整棵树。 +- stylesheet active 不再自动等价于所有节点都必须重新计算;只有受影响的 cascade dependency 才失效。 +- 局部 update 的 `ensure-node-tree`、reconciled objects 和 style calls 应随 affected closure 变化,而不是固定等于全树规模。 +- `owner-rerender`、`span-patch` 和 `paint-patch` 的 computation scope 与 publication scope 不再背离到全树级别。 +- 结构变化、viewport geometry 变化和需要 rollback 的更新仍能扩大 scope,并保留现有 stable identity、TP transaction 和失败回滚语义。 +- retained `viewport-reflow` 只有在 topology、cascade、scroll、overflow 和 owner-scope proof 全部通过后才会选择;其他 viewport change 使用普通正确性路径。 +- scoped style 必须与全量重算在 computed-style 结果(包含 text properties 相关输入)上等价,且同时覆盖 stylesheet 与 inline inheritance 触发器。 +- scoped candidate 必须有明确的 affected-closure proof、旧 root rollback 语义和有限的 rollback/memory retention 策略。 + +## Unknowns 与限制 + +- legacy 没有等价的 ECSS stylesheet/reactive cascade,因此目前不能把两套实现的全部耗时严格按 cascade、layout、ownership 进行一一归因。 +- scroll offset、paint-only update 和 batch update 尚未完成同等阶段拆分;当前结论主要由 content update、viewport resize、pure render 和 stylesheet A/B 支撑。 +- 哪些 ownership/index 结构可以合并,同时不破坏 stable identity、rollback 和 TP scope proof,需要后续通过只读阶段探针和契约测试确认。 +- style snapshot 必须覆盖 theme、ancestor、selector、viewport、face 和其他动态输入的 dependency closure;否则会以性能换取错误渲染。 +- COW candidate 是否安全取决于 stable identity 不依赖对象地址、commit 前 retained state 不可变,以及旧 root/旧 ledger 可用于 rollback。 +- content update 的 closure 可能包含 layout context 和兄弟节点,不能只按 dirty owner 子树裁剪;legacy 的 dirty 传播应作为参照证据逆向记录。 +- scope proof 当前是检查驱动还是构造驱动尚未确定;若原先依靠全树 candidate 获得安全性,scoped/COW 后必须补显式 proof 机制。 +- 绝对耗时受本地 Emacs、显示环境和电源状态影响;本记录使用相同环境下的相对阶段结构和相同输出校验作为主要依据。 + +## 2026-08-09 flex resize checkpoint + +在撤销一次 viewport-dependent generic-cache override 实验后,重新运行了唯一的 public GUI evaluator:`ebox-playground/examples/flex-reference.ebox`。一次真实 `window-resize` 将 preview viewport 从 `256` 改为 `186` 像素,端到端耗时 `1.202837s`,其中 `ebox-rerender-buffer-with-context` 为 `0.748485s`。Preview mount、恰好一次 update、scoped publication、保留的 `viewport-reflow-mixed-scroll`(`reconciled=4`、`full-root=nil`、`scope-fallback=nil`)、与 fresh render 的视觉/text-property 等价性,以及 canvas background 检查全部通过;声明的 `0.5s` 门槛失败。这是当前验收结果,不能称为毫秒级。 + +同一条路径的阶段探针会因 advice 产生测量开销,因此数值只用于方向判断,但已经明确了成本中心:最干净的一次样本中 candidate rendering/materialization 约 `0.38s`,root path 调用了 `142` 次 `ebox--render-layout`。探针观察到 `80` 次 generic cache probe,其中只有 `2` 次命中、`78` 次 no-context;fragment retention 为 `8` 次命中、`37` 次 rerender、`15` 次 store。该样本中 TP 的 snapshot/render/publication 不是独立的主耗时(`tp-text-snapshot` 约 `0.06s`);TP 仍然是必须保留的 identity/publication/rollback 边界。 + +Sol xhigh 的架构审查否决了扩大 generic viewport cacheability 或修改现有 fragment key 作为安全修复:retention lookup 发生在 child measurement 和 Flex reflow 之后,即使命中也不能跳过父级 reflow 或最终 owner/range materialization。下一步真正的 owning boundary 是结构化的 `layout -> fragment/owner result` handoff。可复用结果需要携带 dimensions、owner/property runs,以及局部 containing-block/display signature;之后 `ebox-surface` 才能直接消费这些 runs,不再扫描完整的 propertized string。更大的 slice 必须继续保留 candidate COW、rollback、scoped publication 和与 fresh render 的精确等价性。 + +当前 focused checks 仍然有价值,但不能关闭门槛:Ebox Flex `62/62`、Ebox surface `49/49`,且唯一 evaluator 的正确性检查通过。更广的 core run 在已经很脏的 worktree 中仍有两个已知失败(shrunk definite-item slot count 与 underfilled chrome mapline count);这里将它们记录为回归缺口,不把它们悄悄归因于本 checkpoint。临时 probe daemon 和生产 cache override 都已清理。 + +## 记录边界 + +本文固定根因模型和高层数据流方向,记录了五个已验证的窄实现 slice,但不授权 benchmark 特判、timing hack、松动性能门槛或针对单一 fixture 的缓存分支。剩余 ownership 和 publication 边界仍必须转成可验证的设计合同,再逐步修改代码。 diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index a766dee..11b84f2 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -110,7 +110,7 @@ Selectors query the rendered tree and return public match records. They do not o The same logical id in two buffers resolves to two different handles, so updating one surface cannot accidentally mutate the other. A handle becomes stale when its retained object is removed or its buffer is killed. `ebox-region-update` accepts only a live handle; numeric region ids are internal render metadata and are not an update API. -`ebox-selector-parse` compiles CSS-like strings directly to TP's structured selector AST. Queries support type, `#id`, `.class`, `[key=value]`, descendant whitespace, child `>`, adjacent sibling `+`, and general sibling `~`. Ebox maps logical children and indexed candidates to generic TP subjects; `tp-selector-match-p` is the only final matcher, so query and cascade semantics cannot diverge. Attribute selectors see built-in `:id`/`:key` plus metadata explicitly supplied through `:selector-attributes`, for example `:selector-attributes '((role . button))`; visible content, layout properties, Ebox runtime containers, and internal `:ebox-*` slots never become selector attributes implicitly. +`ebox-selector-parse` compiles CSS-like strings directly to ECSS's structured selector AST. Queries support type, `#id`, `.class`, `[key=value]`, descendant whitespace, child `>`, adjacent sibling `+`, and general sibling `~`. Ebox maps logical children and indexed candidates to ECSS subjects; `ecss-selector-match-p` is the only final matcher, so query and cascade semantics cannot diverge. Attribute selectors see built-in `:id`/`:key` plus metadata explicitly supplied through `:selector-attributes`, for example `:selector-attributes '((role . button))`; visible content, layout properties, Ebox runtime containers, and internal `:ebox-*` slots never become selector attributes implicitly. ```elisp (ebox-selector-query-buffer "*Ebox Demo*" ".toolbar > box.action") diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 75c5323..ec56757 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -110,7 +110,7 @@ Selector 查询已渲染的树并返回公共匹配记录,但不拥有应用 两个 buffer 中相同的逻辑 id 会解析为两个不同 handle,因此更新一个 surface 不会误改另一个。retained object 被删除或 buffer 被 kill 后,原 handle 会变 stale。`ebox-region-update` 只接受 live handle;数字 region id 只是内部渲染元数据,不是更新 API。 -`ebox-selector-parse` 会把 CSS-like 字符串直接编译成 TP 的结构化 selector AST。查询支持 type、`#id`、`.class`、`[key=value]`、空格 descendant、子节点 `>`、相邻兄弟 `+` 和一般兄弟 `~`。Ebox 只负责把逻辑子节点关系与索引候选映射成通用 TP subject,最终匹配统一由 `tp-selector-match-p` 决定,因此 query 与 cascade 不会产生两套语义。属性 selector 只看内建的 `:id`/`:key`,以及通过 `:selector-attributes` 显式传入的元数据,例如 `:selector-attributes '((role . button))`;可见 content、布局属性、Ebox runtime 容器与内部 `:ebox-*` 槽位绝不会被隐式转换成 selector 属性。 +`ebox-selector-parse` 会把 CSS-like 字符串直接编译成 ECSS 的结构化 selector AST。查询支持 type、`#id`、`.class`、`[key=value]`、空格 descendant、子节点 `>`、相邻兄弟 `+` 和一般兄弟 `~`。Ebox 只负责把逻辑子节点关系与索引候选映射成 ECSS subject,最终匹配统一由 `ecss-selector-match-p` 决定,因此 query 与 cascade 不会产生两套语义。属性 selector 只看内建的 `:id`/`:key`,以及通过 `:selector-attributes` 显式传入的元数据,例如 `:selector-attributes '((role . button))`;可见 content、布局属性、Ebox runtime 容器与内部 `:ebox-*` 槽位绝不会被隐式转换成 selector 属性。 ```elisp (ebox-selector-query-buffer "*Ebox Demo*" ".toolbar > box.action") diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index d2d2c2e..c95f46c 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -13,6 +13,10 @@ "Non-nil allows ownership to be added to fresh content lines in place.") (declare-function ebox--maplines "ebox" (function string)) +(declare-function ebox--register-render-owned-text-value + "ebox-render-context" (property value)) +(declare-function ebox--register-render-owned-face-values + "ebox-render-context" (source rendered)) (declare-function ebox--string-repeat-lines "ebox" (string count)) (declare-function ebox-lines-join @@ -177,6 +181,30 @@ immediately after BODY can trigger GC before the updated buffer is visible." ;;; Paint Mapping +(defun ebox-buffer--font-attributes (style) + "Return anonymous face attributes for STYLE typography longhands." + (let (face) + (dolist (entry '((:font-family . :family) + (:font-height . :height) + (:font-weight . :weight) + (:font-slant . :slant))) + (when-let ((value (plist-get style (car entry)))) + (setq face (plist-put face (cdr entry) value)))) + face)) + +(defun ebox-buffer--font-face (style) + "Return STYLE's complete Emacs face value for typography." + (let* ((base (plist-get style :font)) + (base (if (stringp base) (list :family base) base)) + (attributes (ebox-buffer--font-attributes style))) + (cond + ((and attributes base) + (if (and (listp base) (not (keywordp (car-safe base)))) + (cons attributes base) + (list attributes base))) + (attributes attributes) + (base base)))) + (defun ebox-buffer--paint-color (style role) "Return the foreground paint color for STYLE and ROLE." (or (plist-get style :color) @@ -197,14 +225,25 @@ immediately after BODY can trigger GC before the updated buffer is visible." "Return Emacs text properties for computed STYLE in paint ROLE. This backend mapper intentionally accepts computed style facts and emits only buffer-facing paint properties. Layout-only properties never pass through." - (let ((face nil)) + (let ((face (ebox-buffer--font-face style))) (when-let ((foreground (ebox-buffer--paint-color style role))) - (setq face (plist-put face :foreground foreground))) + (setq face + (if (eq foreground 'ebox/default-foreground) + (plist-put face :inherit 'default) + (plist-put face :foreground foreground)))) (when-let ((background (ebox-buffer--paint-background-color style role))) (setq face (plist-put face :background background))) (when face (list 'face face)))) +(defun ebox--propertize-typography (string style) + "Apply STYLE's typography to one copy of STRING." + (if-let ((face (ebox-buffer--font-face style))) + (let ((copy (copy-sequence string))) + (add-face-text-property 0 (length copy) face t copy) + (ebox--register-render-owned-face-values string copy)) + string)) + ;;; Text Property Helpers (defun ebox-buffer-side-border-face (color) @@ -216,50 +255,71 @@ buffer-facing paint properties. Layout-only properties never pass through." (defun ebox--pixel-border (pixel-width height &optional color) "Generate a border line of PIXEL-WIDTH and HEIGHT using optional COLOR." (when (and pixel-width height (> pixel-width 0) (> height 0)) - (let ((line (propertize " " - 'face (ebox-buffer-side-border-face color) - 'display `(space :width (,pixel-width))))) + (let* ((face (ebox-buffer-side-border-face color)) + (display + (ebox--register-render-owned-text-value + 'display `(space :width (,pixel-width)))) + (line (propertize " " + 'face face + 'display display))) + (ebox--register-render-owned-text-value 'face face) (ebox--string-repeat-lines line height)))) (defun ebox--propertize-bgcolor (string bgcolor) "Apply background color BGCOLOR to STRING." - (let* ((string (copy-sequence string)) + (let* ((source string) + (string (copy-sequence source)) (length (length string))) (add-face-text-property 0 length `(:background ,bgcolor) t string) - string)) + (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-color (string color) "Apply foreground COLOR to STRING." - (let* ((string (copy-sequence string)) + (let* ((source string) + (string (copy-sequence source)) (length (length string))) - (add-face-text-property 0 length `(:foreground ,color) t string) - string)) + (add-face-text-property + 0 length + (if (eq color 'ebox/default-foreground) + '(:inherit default) + `(:foreground ,color)) + t string) + (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-colors (string color bgcolor) "Apply foreground COLOR and background BGCOLOR to one copy of STRING." - (let* ((string (copy-sequence string)) + (let* ((source string) + (string (copy-sequence source)) (length (length string))) (when color - (add-face-text-property 0 length `(:foreground ,color) t string)) + (add-face-text-property + 0 length + (if (eq color 'ebox/default-foreground) + '(:inherit default) + `(:foreground ,color)) + t string)) (when bgcolor - (add-face-text-property 0 length `(:background ,bgcolor) t string)) - string)) + (add-face-text-property + 0 length `(:background ,bgcolor) t string)) + (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-overline (string &optional color) "Apply overline to STRING with optional COLOR." - (let ((string (copy-sequence string))) + (let* ((source string) + (string (copy-sequence source))) (add-face-text-property 0 (length string) `(:overline ,(or color t)) t string) - string)) + (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-underline (string &optional color) "Apply underline to STRING with optional COLOR." - (let ((string (copy-sequence string))) + (let* ((source string) + (string (copy-sequence source))) (add-face-text-property 0 (length string) `(:underline (:position t ,@(when color `(:color ,color)))) t string) - string)) + (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-region (string property region-id) "Add PROPERTY with REGION-ID to each line of STRING." @@ -307,13 +367,17 @@ current direct owner, so only a stack change can affect the next output run." (setq owners (append owners (list owner)))) (unless (member region-id owners) (setq owners (append owners (list region-id)))) + (ebox--register-render-owned-text-value + 'ebox-content-owner region-id) + (ebox--register-render-owned-text-value + 'ebox-content-owners owners) (add-text-properties 0 length (list 'ebox-content-owner region-id 'ebox-content-owners owners) string)) (let ((pos 0) - next owners) + next owners original-owners) (while (< pos length) (let* ((owner (get-text-property pos 'ebox-content-owner string)) @@ -321,14 +385,20 @@ current direct owner, so only a stack change can affect the next output run." pos 'ebox-content-owners string)) (complete-stack-p (and owners-prop (member owner owners-prop)))) + (setq original-owners owners-prop) (setq next (ebox--next-content-owner-property-change - pos string length complete-stack-p)) + pos string length complete-stack-p)) (setq owners (if complete-stack-p owners-prop (ebox--content-owner-stack-at string pos)))) (unless (member region-id owners) (setq owners (append owners (list region-id)))) + (ebox--register-render-owned-text-value + 'ebox-content-owner region-id) + (unless (eq owners original-owners) + (ebox--register-render-owned-text-value + 'ebox-content-owners owners)) (add-text-properties pos next (list 'ebox-content-owner region-id 'ebox-content-owners owners) @@ -352,6 +422,7 @@ current direct owner, so only a stack change can affect the next output run." position 'ebox-content-owner result)) (stack (get-text-property position 'ebox-content-owners result)) + (original-stack stack) (complete-stack-p (and stack (member owner stack))) (next (ebox--next-content-owner-property-change position result length complete-stack-p))) @@ -361,6 +432,11 @@ current direct owner, so only a stack change can affect the next output run." (dolist (new-owner owners) (unless (member new-owner stack) (setq stack (append stack (list new-owner))))) + (ebox--register-render-owned-text-value + 'ebox-content-owner final-owner) + (unless (eq stack original-stack) + (ebox--register-render-owned-text-value + 'ebox-content-owners stack)) (add-text-properties position next (list 'ebox-content-owner final-owner @@ -387,16 +463,21 @@ may be added in place." (not (text-property-not-all 0 (length source) 'ebox-content-owners nil source))) - (let ((copy (if mutate-p source (copy-sequence source)))) + (let ((copy (if mutate-p source (copy-sequence source))) + (owners (list region-id))) + (ebox--register-render-owned-text-value + 'ebox-content-owner region-id) + (ebox--register-render-owned-text-value + 'ebox-content-owners owners) (add-text-properties 0 (length copy) (list 'ebox-content-owner region-id - 'ebox-content-owners (list region-id)) + 'ebox-content-owners owners) copy) copy) (if mutate-p (ebox--add-content-owner! source region-id) - (ebox--add-content-owner source region-id))))) + (ebox--add-content-owner source region-id))))) (unless (text-property-not-all 0 (length line) 'ebox-content nil line) (add-text-properties 0 (length line) diff --git a/ebox-flex.el b/ebox-flex.el index 972656a..41cc974 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -11,8 +11,23 @@ (require 'subr-x) (require 'ebox-layout) +(declare-function ebox--flex-fragment-allocation-key + "ebox-fragment" + (source axis main cross align viewport &optional render-context)) +(declare-function ebox--flex-fragment-retention-lookup + "ebox-fragment" (key)) +(declare-function ebox--flex-fragment-retention-store + "ebox-fragment" (key entry side-effects)) +(declare-function ebox--render-cache-render-with-scroll-actions + "ebox-incremental" (node)) +(declare-function ebox--viewport-height-dependent-subtree-p + "ebox-incremental" (node)) + (defvar ebox--propertize-private-content-line-p) (defvar ebox--flex-content-min-width-table) +(defvar ebox-fragment-flex-retention-rerender-count) +(defvar ebox--flex-native-size-lines-backend :unknown + "Cached optional pure geometry backend for batched Flex line sizing.") (defconst ebox--flex-item-prop-keys '(:order :flex :flex-grow :flex-shrink :flex-basis :align-self) @@ -495,6 +510,16 @@ grapheme." (max (if horizontal 0 1) (- target outer-size)))) +(defun ebox--flex-copy-put-size (copy property value) + "Set COPY size PROPERTY to VALUE in engine fields and ECSS declarations." + (plist-put copy property value) + (plist-put + copy :ebox-style-declarations + (ebox-style-merge-declarations + (plist-get copy :ebox-style-declarations) + (ebox-style-compile-declarations + (list property (if (eq property :width) (list value) value)))))) + (defun ebox--flex-copy-node-for-size (node axis main cross stretch) "Return NODE or a copy with flex-computed MAIN/CROSS size." (if (and (listp node) (eq (plist-get node :ebox-type) 'box)) @@ -503,16 +528,16 @@ grapheme." (plist-put copy :region-id (ebox-get node :region-id)) (when main (if (eq axis 'row) - (plist-put copy :width - (ebox--flex-box-main-property node axis main)) - (plist-put copy :height - (ebox--flex-box-main-property node axis main)))) + (ebox--flex-copy-put-size + copy :width (ebox--flex-box-main-property node axis main)) + (ebox--flex-copy-put-size + copy :height (ebox--flex-box-main-property node axis main)))) (when (and cross stretch) (if (eq axis 'row) - (plist-put copy :height - (ebox--flex-box-main-property node 'column cross)) - (plist-put copy :width - (ebox--flex-box-main-property node 'row cross)))) + (ebox--flex-copy-put-size + copy :height (ebox--flex-box-main-property node 'column cross)) + (ebox--flex-copy-put-size + copy :width (ebox--flex-box-main-property node 'row cross)))) copy) node)) @@ -522,36 +547,43 @@ grapheme." (not (ebox--viewport-dependent-size-value-p (plist-get props :flex-basis))))) +(defun ebox--flex-measure-cache-probe (source props axis) + "Return one exact flex measurement cache probe for SOURCE. +The result is nil when SOURCE cannot use the persistent measurement cache; +otherwise it carries the key, signature, and any cached measurement so a +caller can pass the same miss context to the renderer." + (when (ebox--flex-measure-cacheable-p source props) + (let* ((cache-key + (list (ebox--ensure-node-id source) 'flex-measure axis props)) + (signature (ebox--flex-render-cache-signature source))) + (list :cache-key cache-key + :signature signature + :cached (ebox--render-cache-lookup cache-key signature))))) + +(defun ebox--flex-measure-cache-value (probe) + "Return a copied cached measurement from PROBE, or nil on a miss." + (when-let ((cached (plist-get probe :cached))) + (copy-sequence cached))) + (defun ebox--flex-render-cache-signature (node) "Return NODE's flex cache signature after stabilizing runtime identity." (ebox--runtime-node-ids node) (ebox--node-all-region-ids node) (ebox--render-cache-node-signature node)) -(defun ebox--flex-cached-measurement (source props axis) +(defun ebox--flex-cached-measurement (source props axis &optional probe) "Return SOURCE's warm persistent flex measurement, or nil." - (when (and ebox--render-cache-table - (ebox--flex-measure-cacheable-p source props)) - (let* ((cache-key (list (ebox--ensure-node-id source) - 'flex-measure axis props)) - (signature (ebox--flex-render-cache-signature source)) - (cached (ebox--render-cache-lookup cache-key signature))) - (when cached - (copy-sequence cached))))) + (ebox--flex-measure-cache-value + (or probe (ebox--flex-measure-cache-probe source props axis)))) -(defun ebox--flex-measure-source (source props axis &optional viewport) - "Return intrinsic flex measurement plist for SOURCE on AXIS." - (let* ((persistent-cache-key - (and (ebox--flex-measure-cacheable-p source props) - (list (ebox--ensure-node-id source) - 'flex-measure axis props))) - (persistent-signature - (and persistent-cache-key - (ebox--flex-render-cache-signature source))) - (persistent-cached - (and persistent-cache-key - (ebox--render-cache-lookup persistent-cache-key - persistent-signature)))) +(defun ebox--flex-measure-source + (source props axis &optional viewport probe) + "Return intrinsic flex measurement plist for SOURCE on AXIS. +PROBE reuses the caller's exact measurement cache lookup when supplied." + (let* ((probe (or probe (ebox--flex-measure-cache-probe source props axis))) + (persistent-cache-key (plist-get probe :cache-key)) + (persistent-signature (plist-get probe :signature)) + (persistent-cached (plist-get probe :cached))) (if persistent-cached (copy-sequence persistent-cached) (let* ((rendered (progn @@ -618,9 +650,11 @@ measurement and immediately rendering it again at the computed size." "Return flex item metrics for SOURCE and PROPS on AXIS. Prefer an existing complete measurement, then metadata-only fixed-basis metrics, and render intrinsically only when layout semantics require it." - (or (ebox--flex-cached-measurement source props axis) - (ebox--flex-unrendered-measurement source props axis) - (ebox--flex-measure-source source props axis viewport))) + (let* ((probe (ebox--flex-measure-cache-probe source props axis)) + (cached (ebox--flex-measure-cache-value probe))) + (or cached + (ebox--flex-unrendered-measurement source props axis) + (ebox--flex-measure-source source props axis viewport probe)))) (defun ebox--flex-pad-width (string width &optional align) "Pad every line of STRING to WIDTH pixels." @@ -829,20 +863,55 @@ Return a sized render entry containing the rendered string and dimensions." (let* ((node (unless can-use-measured (ebox--flex-copy-node-for-size source axis main cross stretch))) - (rendered (if can-use-measured - (plist-get item :rendered) - (ebox--flex-bind-source-viewport - source item-viewport - (lambda () (ebox-render (or node source))))))) + (fragment-key + (and (not can-use-measured) + (listp source) + (not (stringp source)) + (numberp item-viewport) + (ebox--flex-fragment-allocation-key + source axis main cross align item-viewport + (list :intrinsic-layout-measurement + ebox--intrinsic-layout-measurement + :scroll-window-render-disabled + ebox--scroll-window-render-disabled + :viewport-height-dependent + (if (fboundp + 'ebox--viewport-height-dependent-subtree-p) + (ebox--viewport-height-dependent-subtree-p source) + :unknown))))) + (fragment-entry + (and fragment-key + (ebox--flex-fragment-retention-lookup fragment-key))) + fragment-side-effects + (rendered + (cond + (can-use-measured + (plist-get item :rendered)) + (fragment-entry + (ebox--flex-sized-entry-rendered fragment-entry)) + ((and fragment-key + (fboundp 'ebox--render-cache-render-with-scroll-actions)) + (let ((ebox-viewport-width item-viewport)) + (pcase-let ((`(,captured-rendered ,captured-side-effects) + (ebox--render-cache-render-with-scroll-actions + (or node source)))) + (setq fragment-side-effects captured-side-effects) + captured-rendered))) + (t + (ebox--flex-bind-source-viewport + source item-viewport + (lambda () (ebox-render (or node source)))))))) (ebox--flex-recache-source-boxes source) - (when (and main + (when (and (not fragment-entry) + main (not (and can-use-measured (= main render-main)))) (if (eq axis 'row) (setq rendered (ebox--flex-pad-width rendered main)) (setq rendered (ebox--flex-pad-height rendered main 0 (ebox--flex-string-width rendered))))) - (when (and cross + (when (and (not fragment-entry) + cross (not (and can-use-measured (= cross render-cross)))) (if (eq axis 'row) (let* ((rendered-width @@ -857,21 +926,27 @@ Return a sized render entry containing the rendered string and dimensions." (let ((entry (ebox--flex-sized-entry rendered - (if (eq axis 'row) - (or main (if can-use-measured - render-main - (ebox--flex-string-width rendered))) - (or main (ebox-string-height rendered))) - (if (eq axis 'row) - (or cross (if can-use-measured - render-cross - (ebox-string-height rendered))) - (or cross (if can-use-measured - render-cross - (ebox--flex-string-width rendered))))))) + (or (and fragment-entry (plist-get fragment-entry :main)) + (if (eq axis 'row) + (or main (if can-use-measured + render-main + (ebox--flex-string-width rendered))) + (or main (ebox-string-height rendered)))) + (or (and fragment-entry (plist-get fragment-entry :cross)) + (if (eq axis 'row) + (or cross (if can-use-measured + render-cross + (ebox-string-height rendered))) + (or cross (if can-use-measured + render-cross + (ebox--flex-string-width rendered)))))))) (plist-put item :render-cache (cons (cons cache-key entry) (plist-get item :render-cache))) + (when (and fragment-key (not fragment-entry)) + (cl-incf ebox-fragment-flex-retention-rerender-count) + (ebox--flex-fragment-retention-store + fragment-key entry fragment-side-effects)) (when persistent-cache-key (ebox--render-cache-store persistent-cache-key persistent-signature @@ -1016,6 +1091,29 @@ Return a sized render entry containing the rendered string and dimensions." (push (nreverse current) lines)) (nreverse lines)))) +(defun ebox--flex-native-size-lines-backend () + "Return the optional native Flex geometry function, or nil." + (when (eq ebox--flex-native-size-lines-backend :unknown) + (setq ebox--flex-native-size-lines-backend + (when (require 'ebox-native-reflow nil t) + (when (and (fboundp 'ebox-native-reflow-layout-ready-p) + (ebox-native-reflow-layout-ready-p) + (fboundp 'ebox-native-reflow-flex-size-lines)) + #'ebox-native-reflow-flex-size-lines)))) + (unless (eq ebox--flex-native-size-lines-backend :unknown) + ebox--flex-native-size-lines-backend)) + +(defun ebox--flex-size-lines (lines main-limit main-gap) + "Return sized copies for LINES using the pure geometry boundary when safe. +The Elisp scalar algorithm remains the exact fallback and owns all item +metadata and rendering decisions." + (or (when (and main-limit (integerp main-gap)) + (when-let ((backend (ebox--flex-native-size-lines-backend))) + (funcall backend lines main-limit main-gap))) + (mapcar (lambda (line) + (ebox--flex-size-line line main-limit main-gap)) + lines))) + (defun ebox--flex-size-line (line main-limit main-gap) "Return LINE items with computed target main sizes." (let* ((count (length line)) @@ -1228,9 +1326,7 @@ Return a sized render entry containing the rendered string and dimensions." (justify (plist-get props :justify-content)) (align-content (plist-get props :align-content)) (single-line (eq (plist-get props :flex-wrap) 'nowrap)) - (sized (mapcar (lambda (line) - (ebox--flex-size-line line main-size main-gap)) - lines)) + (sized (ebox--flex-size-lines lines main-size main-gap)) (line-mains (mapcar (lambda (line) (ebox--flex-line-main-size line main-gap)) sized)) @@ -1271,9 +1367,7 @@ Return a sized render entry containing the rendered string and dimensions." (justify (plist-get props :justify-content)) (align-content (plist-get props :align-content)) (single-line (eq (plist-get props :flex-wrap) 'nowrap)) - (sized (mapcar (lambda (line) - (ebox--flex-size-line line main-size main-gap)) - lines)) + (sized (ebox--flex-size-lines lines main-size main-gap)) (crosses (if (and cross-size single-line) (list cross-size) (mapcar (lambda (line) @@ -1306,11 +1400,17 @@ Return a sized render entry containing the rendered string and dimensions." "Wrap NODE with flex item metadata and optional box properties from PLIST." (let ((item-props (ebox--plist-keep-keys plist ebox--flex-item-prop-keys)) (box-props (ebox--plist-remove-keys plist ebox--flex-item-prop-keys))) - (list :ebox-type 'flex-item - :display '(block flow) - :node (ebox--flex-wrap-node-with-box-props - 'ebox-flex-item node box-props) - :props item-props))) + (let* ((source (ebox--flex-wrap-node-with-box-props + 'ebox-flex-item node box-props)) + (source (copy-sequence source)) + (declarations + (append (plist-get source :ebox-style-declarations) + (ebox-style-compile-declarations item-props)))) + (plist-put source :ebox-style-declarations declarations) + (list :ebox-type 'flex-item + :display '(block flow) + :node source + :props item-props)))) ;;;###autoload (defun ebox-flex (&rest items) @@ -1332,13 +1432,21 @@ its child." raw-props ebox--flex-container-layout-prop-keys)) (wrapper-box (ebox--flex-make-wrapper-box box-props)) (props (ebox--flex-normalize-container-props raw-props)) - (children (delq nil (cdr split)))) - (list :ebox-type 'flex - :display '(block flex) - :props props - :raw-props raw-props - :box wrapper-box - :children children))) + (children (delq nil (cdr split))) + (node (list :ebox-type 'flex + :display '(block flex) + :props props + :raw-props raw-props + :ebox-style-declarations + (ebox-style-compile-declarations raw-props) + :box wrapper-box + :children children))) + (when wrapper-box + (plist-put wrapper-box :ebox-style-wrapper t)) + (dolist (key '(:id :class :selector-state :selector-attributes :key)) + (when (plist-member raw-props key) + (plist-put node key (plist-get raw-props key)))) + node)) (defun ebox--render-flex-box (node rendered) "Render flex NODE's container box around RENDERED, when present." diff --git a/ebox-fragment.el b/ebox-fragment.el index 1ce0135..3203d75 100644 --- a/ebox-fragment.el +++ b/ebox-fragment.el @@ -11,6 +11,92 @@ (require 'ebox-tree) (require 'ebox-measure) +(declare-function ebox--current-display-signature "ebox-measure" ()) + +(defvar ebox--layout-fragments-table nil + "Dynamic published-generation layout fragment retention table.") + +(defvar ebox--layout-fragments-reuse-p nil + "Non-nil when the current candidate may reuse retained layout fragments.") + +(defconst ebox-fragment-flex-retention-max-entries 512 + "Maximum number of retained final-sized Flex fragments per generation.") + +(defvar ebox-fragment-flex-retention-hit-count 0 + "Diagnostic count of retained final-sized Flex child fragment hits.") + +(defvar ebox-fragment-flex-retention-rerender-count 0 + "Diagnostic count of final-sized Flex child fragment rerenders.") + +(defvar ebox-fragment-flex-retention-store-count 0 + "Diagnostic count of retained final-sized Flex child fragment stores.") + +(defun ebox-fragment-flex-allocation-key + (source axis main cross align viewport &optional render-context) + "Return exact retained-fragment key for SOURCE in Flex allocation context. +RENDER-CONTEXT contains layout-mode inputs that affect the rendered child but +are not part of its final allocated geometry." + (when (and (hash-table-p ebox--layout-fragments-table) + (listp source) + (not (stringp source)) + (numberp viewport)) + (let ((height-key + (if (and (plist-member render-context + :viewport-height-dependent) + (null (plist-get render-context + :viewport-height-dependent))) + :viewport-height-independent + (and (boundp 'ebox-viewport-height) ebox-viewport-height)))) + (list (ebox--ensure-node-id source) + 'flex-final-sized + axis main cross align viewport + height-key + render-context + (and (fboundp 'ebox--current-display-signature) + (ebox--current-display-signature)))))) + +(defun ebox-fragment-flex-retained-side-effects-reusable-p (metadata) + "Return non-nil when retained fragment METADATA has no scroll effects. +Even a clear action is generation-sensitive: replaying or skipping it can +leave a stale scroll state after an A-to-B-to-A candidate sequence." + (and (listp metadata) + (not (plist-get metadata :stateful-p)) + (plist-member metadata :scroll-actions) + (null (plist-get metadata :scroll-actions)))) + +(defun ebox-fragment-flex-retention-lookup (key) + "Return retained Flex fragment entry for KEY, or nil." + (when (and key (hash-table-p ebox--layout-fragments-table)) + (let ((entry (gethash key ebox--layout-fragments-table))) + (when (and entry + ebox--layout-fragments-reuse-p + (ebox-fragment-flex-retained-side-effects-reusable-p + (plist-get entry :side-effects))) + (cl-incf ebox-fragment-flex-retention-hit-count) + (plist-get entry :entry))))) + +(defun ebox-fragment-flex-retention-store (key entry side-effects) + "Store retained Flex fragment ENTRY under KEY with SIDE-EFFECTS." + (when (and key entry + (ebox-fragment-flex-retained-side-effects-reusable-p + side-effects) + (hash-table-p ebox--layout-fragments-table)) + (when (and (not (gethash key ebox--layout-fragments-table)) + (>= (hash-table-count ebox--layout-fragments-table) + ebox-fragment-flex-retention-max-entries)) + (let (old-key) + (maphash + (lambda (candidate-key _old-entry) + (setq old-key candidate-key)) + ebox--layout-fragments-table) + (when old-key + (remhash old-key ebox--layout-fragments-table)))) + (puthash key + (list :entry entry :side-effects side-effects) + ebox--layout-fragments-table) + (cl-incf ebox-fragment-flex-retention-store-count)) + entry) + (cl-defstruct (ebox-fragment (:constructor ebox-fragment-create)) node-id type display region-ids child-ids style-signature size @@ -197,6 +283,12 @@ include buffer spans and line signatures." #'ebox-fragment-node-layout-snapshot) (defalias 'ebox--layout-snapshot-dirty-kind #'ebox-fragment-layout-snapshot-dirty-kind) +(defalias 'ebox--flex-fragment-allocation-key + #'ebox-fragment-flex-allocation-key) +(defalias 'ebox--flex-fragment-retention-lookup + #'ebox-fragment-flex-retention-lookup) +(defalias 'ebox--flex-fragment-retention-store + #'ebox-fragment-flex-retention-store) (provide 'ebox-fragment) diff --git a/ebox-grid.el b/ebox-grid.el index a43e28f..2182f6f 100644 --- a/ebox-grid.el +++ b/ebox-grid.el @@ -162,7 +162,8 @@ Supported properties are `:grid-column', `:grid-row', `:grid-column-span', and `:grid-row-span'." (unless (and (listp node) (plist-member node :ebox-type)) (error "ebox-grid-item: NODE is not an Ebox node: %S" node)) - (let ((copy (copy-sequence node))) + (let ((copy (copy-sequence node)) + (declarations (ebox-style-compile-declarations props t))) (while props (let* ((key (pop props)) (value (progn @@ -172,6 +173,9 @@ Supported properties are `:grid-column', `:grid-row', (unless (memq key ebox--grid-item-prop-keys) (error "ebox-grid-item: unsupported property %S" key)) (plist-put copy key value))) + (plist-put copy :ebox-style-declarations + (append (plist-get copy :ebox-style-declarations) + declarations)) copy)) (defun ebox-grid--placement-part (value default) @@ -677,13 +681,21 @@ Children may carry `:grid-column' and `:grid-row' placement properties. (box-props (cl-loop for (key value) on raw-props by #'cddr unless (memq key ebox--grid-layout-prop-keys) collect key and collect value)) - (wrapper (ebox-grid--wrapper box-props))) - (list :ebox-type 'grid - :display '(block grid) - :props raw-props - :raw-props raw-props - :box wrapper - :children children))) + (wrapper (ebox-grid--wrapper box-props)) + (node (list :ebox-type 'grid + :display '(block grid) + :props raw-props + :raw-props raw-props + :ebox-style-declarations + (ebox-style-compile-declarations raw-props) + :box wrapper + :children children))) + (when wrapper + (plist-put wrapper :ebox-style-wrapper t)) + (dolist (key '(:id :class :selector-state :selector-attributes :key)) + (when (plist-member raw-props key) + (plist-put node key (plist-get raw-props key)))) + node)) (provide 'ebox-grid) diff --git a/ebox-incremental.el b/ebox-incremental.el index 208fe8f..9107da9 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -63,6 +63,8 @@ "ebox" (root region-id)) (declare-function ebox--literal-root-pixel-width "ebox" (box)) +(declare-function ebox--format-content + "ebox-layout" (box)) (declare-function ebox-put "ebox" (box key value)) (declare-function ebox--confirm-reflow-prewarm-scratch "ebox" (buffer region-id revision)) @@ -72,6 +74,8 @@ "ebox-cache" (buffer)) (declare-function ebox-cache-restore-report "ebox-cache" (buffer snapshot)) +(declare-function ebox-style-cascade-active-p + "ebox-style" ()) (defvar ebox-viewport-width) (defvar ebox-viewport-height) @@ -84,6 +88,8 @@ (defvar ebox--render-region-id) (defvar ebox--defer-scroll-content-index) (defvar ebox--flex-content-min-width-table) +(defvar ebox--layout-fragments-table) +(defvar ebox--layout-fragments-reuse-p) ;;; Render Cache @@ -357,6 +363,14 @@ Kept as a narrow predicate for callers that need box-only sizing semantics." (defun ebox--render-cache-node-signature (node) "Return a cache signature for NODE render output." + ;; Cache signatures include rendered region identity because cached text + ;; properties carry that identity. Materialize both identity domains + ;; before memoizing the body signature; otherwise the first cache probe can + ;; run before `ebox--render-box' assigns :region-id and every later probe + ;; misses solely because the same node has since been rendered. + (when (listp node) + (ebox--runtime-node-ids node) + (ebox--node-all-region-ids node)) (list (ebox--current-display-signature) (when (and (ebox--viewport-dependent-subtree-p node) (not (ebox--render-cache-contained-viewport-node-p @@ -827,6 +841,7 @@ CACHE-PROBE may supply a prior exact miss from an accelerator boundary." (if-let ((prepared (and force (ebox--take-prepared-root-render node)))) (prog1 prepared + (ebox--replay-render-output-provenance prepared) (ebox--flex-recache-source-boxes node)) (let ((probe (or cache-probe (ebox--render-cache-probe node force)))) @@ -834,7 +849,8 @@ CACHE-PROBE may supply a prior exact miss from an accelerator boundary." ((null probe) (ebox-render node)) ((plist-member probe :rendered) - (plist-get probe :rendered)) + (ebox--replay-render-output-provenance + (plist-get probe :rendered))) (t (pcase-let* ((`(,rendered ,side-effects) (ebox--render-cache-render-with-scroll-actions node)) @@ -859,7 +875,8 @@ CACHE-PROBE may supply a prior exact miss from an accelerator boundary." (unless (and stateful-p (not (or ebox--render-cache-scroll-state-restorable-p portable-p))) - (ebox--render-cache-store-context probe rendered metadata)) + (ebox--render-cache-store-context probe rendered metadata) + (ebox--record-render-output-provenance rendered)) rendered)))))) ;;; Buffer Runtime State @@ -1079,6 +1096,8 @@ same traversal in complete-render overwrite order." :layout-snapshot-detail-generation 0 :runtime-revision 0 :last-update-report nil :render-cache (make-hash-table :test 'equal) + :layout-fragments (make-hash-table :test 'equal) + :layout-fragments-reuse-p nil :detached-identity-history (ebox-incremental--make-detached-history :table (make-hash-table :test 'equal) @@ -3549,8 +3568,9 @@ legacy callers." (defun ebox-incremental-surface-batch-root (buffer) "Return BUFFER's unpublished surface batch root, or nil." - (plist-get (ebox-incremental--batch-state buffer) - :surface-candidate-root)) + (let ((state (ebox-incremental--batch-state buffer))) + (and (plist-get state :active) + (plist-get state :surface-candidate-root)))) (defun ebox-incremental-record-surface-region-change (buffer candidate-root region-id handle dirty-kind changed-keys @@ -3895,6 +3915,38 @@ logical tree crosses that boundary." replacement new-child))) +(defun ebox-incremental-surface-region-candidate-root (buffer region-id) + "Return a copy-on-write candidate root for REGION-ID in BUFFER. +Only the region owner and its ancestor path are copied; untouched runtime +subtrees remain shared with the published root. Return nil when the retained +index cannot identify a directly replaceable region owner, leaving callers to +use their ordinary isolated-copy path." + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (node-table (plist-get state :node-table)) + (parent-table (plist-get state :parent-table)) + (region-node-table (plist-get state :region-node-table)) + (owner-id (and (hash-table-p region-node-table) + (gethash region-id region-node-table))) + (owner (and (hash-table-p node-table) + (gethash owner-id node-table))) + (box + (pcase (and owner (plist-get owner :ebox-type)) + ('box owner) + ('flex (plist-get owner :box))))) + (when (and root owner box + (hash-table-p parent-table) + (equal (plist-get box :region-id) region-id)) + (let* ((candidate-box (copy-sequence box)) + (replacement + (if (eq owner box) + candidate-box + (ebox-tree-copy-with-direct-child-replacements + owner (list (cons box candidate-box)))))) + (ebox-incremental--candidate-path-copy-one + root owner-id replacement + (list :node-table node-table :parent-table parent-table)))))) + (defun ebox-incremental--candidate-path-copy-many (root index replacements) "Return ROOT with node-id REPLACEMENTS applied by one bottom-up path copy." @@ -5030,6 +5082,28 @@ replace those O(n) table copies without changing this delta contract." :touched-count (length touched) :removed-count (length removed)))))))))) +(defun ebox-incremental--candidate-shares-published-node-p + (old-state candidate-root) + "Return non-nil when CANDIDATE-ROOT shares a published runtime node. +The search stops at the first shared node, so a copy-on-write candidate is +recognized from its first untouched sibling without walking the whole tree." + (let ((old-root (plist-get old-state :root-node)) + (old-node-table (plist-get old-state :node-table))) + (and (hash-table-p old-node-table) + (catch 'shared + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (let ((node-id (plist-get node :node-id))) + (when (and node-id + (not (eq node old-root)) + (eq node (gethash node-id old-node-table))) + (throw 'shared t))) + (dolist (child (ebox-tree--children-raw node)) + (visit child))))) + (visit candidate-root) + nil))))) + (defun ebox-incremental--declarative-dirty-set (old-state candidate-root candidate-index) "Return source-level dirty entries from OLD-STATE to CANDIDATE-ROOT. @@ -5087,10 +5161,26 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." new-table)) (nreverse dirty))) +(defun ebox-incremental--isolate-candidate-caches (state) + "Copy mutable Ebox caches in unpublished STATE. +Candidate layout may replace entries while proving a publication. Each +candidate therefore owns its cache tables until TP commits the generation." + (dolist (entry '((:render-cache . equal) + (:layout-fragments . equal) + (:render-signature-cache . eq) + (:flex-content-min-widths . eq) + (:viewport-height-dependent-subtree-cache . eq))) + (let ((key (car entry)) + (value (plist-get state (car entry)))) + (when (hash-table-p value) + (plist-put state key (copy-hash-table value))))) + state) + (defun ebox-incremental--candidate-state (old-state candidate-root candidate-index prepared) "Return unpublished runtime state for a prepared declarative root." - (let ((state (copy-sequence old-state))) + (let ((state (ebox-incremental--isolate-candidate-caches + (copy-sequence old-state)))) (dolist (entry `((:root-node ,candidate-root) @@ -5105,6 +5195,9 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." ,(1+ (or (plist-get old-state :runtime-revision) 0))) (:display-signature ,(plist-get prepared :display-signature)) (:render-cache ,(plist-get prepared :render-cache)) + (:layout-fragments ,(plist-get prepared :layout-fragments)) + (:layout-fragments-reuse-p + ,(plist-get prepared :layout-fragments-reuse-p)) (:detached-identity-history ,(or (plist-get prepared :detached-identity-history) (plist-get old-state :detached-identity-history))) @@ -5154,7 +5247,7 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." ,(plist-get candidate-index :native-node-postorder)))) (setq state (plist-put state (car entry) (cadr entry)))) - state)) + (ebox-incremental--isolate-candidate-caches state))) (defun ebox-incremental--seed-candidate-node-cache (old-cache old-node-table candidate-node-table) @@ -5239,6 +5332,9 @@ must be recomputed in the next publication." (let* ((old-root (plist-get old-state :root-node)) (render-cache (or (plist-get old-state :render-cache) + (make-hash-table :test 'equal))) + (layout-fragments + (or (plist-get old-state :layout-fragments) (make-hash-table :test 'equal)))) (let* ((candidate-index (or (plist-get local-preparation :index) @@ -5295,6 +5391,8 @@ must be recomputed in the next publication." :dirty-set dirty-set :display-signature current-display-signature :render-cache render-cache + :layout-fragments layout-fragments + :layout-fragments-reuse-p nil :detached-identity-history (or (plist-get local-preparation :detached-identity-history) @@ -5680,6 +5778,213 @@ render whose line count moved is unpublishable there by construction." :publication-scope 'layout-owners)))) (ebox-incremental--commit-report prepared report candidate-state))) +(defun ebox-incremental--paint-projection-p + (old-state owner-plan dirty-set) + "Return non-nil when OWNER-PLAN can reuse OLD-STATE spatial topology." + (and owner-plan + (null (plist-get old-state :scroll-region-ids)) + (cl-every (lambda (op) (eq (plist-get op :op) 'paint-patch)) + owner-plan) + (cl-every + (lambda (dirty) + (cl-every (lambda (key) + (memq key ebox--paint-style-signature-keys)) + (plist-get dirty :changed-keys))) + dirty-set))) + +(defun ebox-incremental--hash-key-set-equal-p (left right) + "Return non-nil when hash tables LEFT and RIGHT have the same keys." + (and (hash-table-p left) + (hash-table-p right) + (= (hash-table-count left) (hash-table-count right)) + (let ((equal-p t) + (missing (make-symbol "ebox-missing-key"))) + (maphash + (lambda (key _value) + (when (eq (gethash key right missing) missing) + (setq equal-p nil))) + left) + equal-p))) + +(defun ebox-incremental--span-patch-identity-stable-p + (buffer old-state candidate-state prepared owner-id) + "Return non-nil when a span patch preserves candidate identity topology. +The candidate index is already the proof boundary for source identity. This +check verifies its observable sets and the copied path's parent slots before +the surface is allowed to reuse retained TP objects." + (let* ((old-node-table (plist-get old-state :node-table)) + (new-node-table (plist-get candidate-state :node-table)) + (old-parent-table (plist-get old-state :parent-table)) + (new-parent-table (plist-get candidate-state :parent-table)) + (touched (delete-dups + (append (plist-get prepared :touched-node-ids) + (list owner-id + (ebox--buffer-root-node-id buffer)))))) + (and (ebox-incremental--hash-key-set-equal-p + old-node-table new-node-table) + (ebox-incremental--hash-key-set-equal-p + (plist-get old-state :region-id-set) + (plist-get candidate-state :region-id-set)) + (cl-every + (lambda (node-id) + (let ((old-node (gethash node-id old-node-table)) + (new-node (gethash node-id new-node-table))) + (and old-node new-node + (eq (plist-get old-node :ebox-type) + (plist-get new-node :ebox-type)) + (equal (plist-get old-node :key) + (plist-get new-node :key)) + (equal (gethash node-id old-parent-table) + (gethash node-id new-parent-table))))) + (delq nil touched))))) + +(defun ebox-incremental--path-span-line-count-stable-p + (buffer node spans) + "Return non-nil when path-copied NODE keeps the published line count." + (and (eq (plist-get node :ebox-type) 'box) + (numberp (ebox-get node :width)) + (null (plist-get node :ebox-content-node)) + (= (length spans) + (with-current-buffer buffer + (length (ebox-string-lines (ebox--format-content node))))))) + +(defun ebox-incremental--span-patch-projection-proof + (buffer old-state prepared candidate-state owner-plan + &optional path-copied-p) + "Return strict span projection metadata, or nil when it is unsafe. +This first slice deliberately accepts only one fixed-footprint content owner. +The surface still performs the output and text-property proof before using the +metadata; this predicate only authorizes the local attempt." + (when (and (= (length owner-plan) 1) + (= (length (plist-get prepared :dirty-set)) 1)) + (let* ((operation (car owner-plan)) + (dirty (car (plist-get prepared :dirty-set))) + (owner-id (plist-get operation :owner-id)) + (changed-keys (plist-get dirty :changed-keys)) + (old-node (gethash owner-id (plist-get old-state :node-table))) + (new-node (gethash owner-id (plist-get candidate-state :node-table))) + (snapshot + (and old-node + (ebox--ensure-layout-snapshot-details buffer owner-id))) + (spans (and snapshot (plist-get snapshot :buffer-spans))) + (axes (plist-get old-state :viewport-dependent-node-id-axes))) + (when (and (eq (plist-get operation :op) 'span-patch) + (eq (plist-get dirty :dirty-kind) 'geometry) + (equal changed-keys '(:content)) + (not (plist-get dirty :children)) + (null (plist-get dirty :node-ids)) + (not (plist-get old-state :cascade-active-p)) + (not (plist-get old-state :cascade-required-p)) + (null (plist-get old-state :scroll-region-ids)) + (or (null (plist-get candidate-state :scroll-region-ids)) + (null (ebox-incremental--hash-keys + (plist-get candidate-state :scroll-state-table)))) + (not (cl-some #'identity (and axes (append (car axes) + (cdr axes))))) + old-node new-node + (eq (plist-get old-node :ebox-type) + (plist-get new-node :ebox-type)) + (equal (plist-get old-node :key) + (plist-get new-node :key)) + (ebox-incremental--node-child-ids-equal-p + old-node new-node) + (equal (ebox-fragment-node-style-signature old-node) + (ebox-fragment-node-style-signature new-node)) + (ebox-incremental--same-root-surface-identity-p + (plist-get old-state :root-node) + (plist-get candidate-state :root-node)) + (ebox-incremental--span-patch-identity-stable-p + buffer old-state candidate-state prepared owner-id) + (ebox--layout-snapshot-detailed-p snapshot) + spans + (or (not path-copied-p) + (ebox-incremental--path-span-line-count-stable-p + buffer new-node spans)) + (plist-get snapshot :span-footprint-signature) + (plist-get snapshot :external-footprint-signature) + (plist-get snapshot :parent-slot-signature) + (plist-get snapshot :role-topology-signature) + (plist-get snapshot :overflow-signature) + ;; Partial row slots are disjoint by design; the backend + ;; shapes each slot independently before the replacement. + (with-current-buffer buffer + (or (ebox-buffer--partial-line-slots-p spans) + (ebox--spans-contiguous-lines-p spans))) + (not (ebox--node-visible-overflow-p old-node)) + (not (ebox--ancestor-own-overflow-visible-p buffer owner-id))) + (list :owner-id owner-id :dirty-set (list dirty)))))) + +(defun ebox-incremental--surface-state-overrides + (old-state prepared candidate-state projection-kind + &optional span-proof source-path-copied-p) + "Return surface overrides for PREPARED CANDIDATE-STATE." + (let ((overrides + (list :viewport-width (plist-get old-state :viewport-width) + :viewport-height (plist-get old-state :viewport-height) + :display-signature (plist-get prepared :display-signature) + :scroll-state-table (plist-get prepared :scroll-state-table) + :source-path-copied-p source-path-copied-p + :runtime-index-prepared-p + (memq projection-kind '(span-patch viewport-reflow)) + :logical-candidate-p (plist-get prepared :logical-candidate-p) + :detached-identity-history + (plist-get prepared :detached-identity-history)))) + (if (not (memq projection-kind '(paint span-patch))) + overrides + (append + overrides + (list :layout-snapshots (plist-get candidate-state :layout-snapshots) + :layout-snapshots-complete-p + (plist-get candidate-state :layout-snapshots-complete-p) + :layout-snapshot-detail-generation + (plist-get candidate-state :layout-snapshot-detail-generation) + :render-cache (plist-get candidate-state :render-cache) + :render-signature-cache + (plist-get candidate-state :render-signature-cache) + :flex-content-min-widths + (if (eq projection-kind 'span-patch) + (plist-get candidate-state :flex-content-min-widths) + (plist-get old-state :flex-content-min-widths)) + :viewport-height-dependent-subtree-cache + (plist-get candidate-state + :viewport-height-dependent-subtree-cache) + :viewport-dependent-node-ids-ready + (plist-get candidate-state :viewport-dependent-node-ids-ready) + :viewport-dependent-node-ids + (plist-get candidate-state :viewport-dependent-node-ids) + :viewport-dependent-node-id-axes + (plist-get candidate-state :viewport-dependent-node-id-axes) + :scroll-region-ids (plist-get candidate-state :scroll-region-ids) + :node-table (plist-get candidate-state :node-table) + :parent-table (plist-get candidate-state :parent-table) + :region-id-set (plist-get candidate-state :region-id-set) + :region-node-table (plist-get candidate-state :region-node-table) + :region-box-count-table + (plist-get candidate-state :region-box-count-table) + :region-box-table (plist-get candidate-state :region-box-table) + :host-ref-table (plist-get candidate-state :host-ref-table) + :selector-id-table (plist-get candidate-state :selector-id-table) + :selector-class-table + (plist-get candidate-state :selector-class-table) + :selector-type-table + (plist-get candidate-state :selector-type-table) + :runtime-type-count-table + (plist-get candidate-state :runtime-type-count-table) + :selector-index-stale-p + (plist-get candidate-state :selector-index-stale-p) + :native-node-postorder + (plist-get candidate-state :native-node-postorder) + :surface-node-object-table + (and (eq projection-kind 'span-patch) + (plist-get old-state :surface-node-object-table)) + :paint-dirty-set + (and (eq projection-kind 'paint) + (plist-get prepared :dirty-set)) + :span-patch-owner-id + (and span-proof (plist-get span-proof :owner-id)) + :span-patch-dirty-set + (and span-proof (plist-get span-proof :dirty-set))))))) + (defun ebox-incremental--same-root-surface-identity-p (old-root new-root) "Return non-nil when OLD-ROOT and NEW-ROOT retain one TP root object." (and (eq (plist-get old-root :ebox-type) @@ -5710,7 +6015,18 @@ render whose line count moved is unpublishable there by construction." (plist-get prepared :dirty-set)))))) (report (ebox-incremental--layout-owner-report - prepared owner-plan candidate-state))) + prepared owner-plan candidate-state)) + (paint-p + (ebox-incremental--paint-projection-p + old-state owner-plan (plist-get prepared :dirty-set))) + (span-proof + (unless paint-p + (ebox-incremental--span-patch-projection-proof + buffer old-state prepared candidate-state owner-plan))) + (projection-kind + (cond + (paint-p 'paint) + (span-proof 'span-patch)))) (unless same-root-identity-p (setq report (plist-put report :strategy 'root-rerender)) (setq report (plist-put report :publication-scope 'root)) @@ -5718,18 +6034,11 @@ render whose line count moved is unpublishable there by construction." (list :root (plist-get prepared :root) :scope-node-ids scope-node-ids :report-base report + :projection-kind projection-kind :preserve-identities-p t :state-overrides - (list :viewport-width (plist-get old-state :viewport-width) - :viewport-height (plist-get old-state :viewport-height) - :display-signature - (plist-get prepared :display-signature) - :scroll-state-table - (plist-get prepared :scroll-state-table) - :logical-candidate-p - (plist-get prepared :logical-candidate-p) - :detached-identity-history - (plist-get prepared :detached-identity-history))))) + (ebox-incremental--surface-state-overrides + old-state prepared candidate-state projection-kind span-proof)))) (defun ebox-incremental--plist-overlay (base overrides) "Return BASE with every property in OVERRIDES replaced." @@ -5738,48 +6047,105 @@ render whose line count moved is unpublishable there by construction." (setq base (plist-put base (pop overrides) (pop overrides)))) base) +(defun ebox-incremental--prepare-scoped-candidate + (buffer old-state next-root isolated-candidate-p) + "Prepare NEXT-ROOT and its scoped owner plan for BUFFER. +Return the intermediate candidate state, owner plan, projection proof, and +whether NEXT-ROOT shares untouched published nodes." + (let* ((path-copied-p + (and isolated-candidate-p + (ebox-incremental--candidate-shares-published-node-p + old-state next-root))) + (local-preparation + (and path-copied-p + (ebox-incremental--candidate-local-index-delta + old-state next-root))) + (prepared + (if isolated-candidate-p + (ebox-incremental--prepare-declarative-runtime + buffer old-state next-root nil local-preparation) + (ebox-incremental--prepare-declarative-root + buffer old-state next-root))) + (candidate-state + (ebox-incremental--candidate-state + old-state (plist-get prepared :root) + (plist-get prepared :index) prepared)) + (owner-plan + (ebox-incremental--layout-owner-plan + buffer old-state candidate-state + (plist-get prepared :dirty-set))) + (scope-node-ids + (delete-dups + (or (mapcar (lambda (op) (plist-get op :owner-id)) owner-plan) + (mapcar (lambda (entry) (plist-get entry :node-id)) + (plist-get prepared :dirty-set))))) + (paint-p + (ebox-incremental--paint-projection-p + old-state owner-plan (plist-get prepared :dirty-set))) + (span-proof + (unless paint-p + (ebox-incremental--span-patch-projection-proof + buffer old-state prepared candidate-state owner-plan + path-copied-p))) + (projection-kind + (cond + (paint-p 'paint) + (span-proof 'span-patch)))) + (list :prepared prepared + :candidate-state candidate-state + :owner-plan owner-plan + :scope-node-ids scope-node-ids + :span-proof span-proof + :projection-kind projection-kind + :path-copied-p path-copied-p))) + (defun ebox-incremental-prepare-scoped-commit - (buffer next-root report-overrides) + (buffer next-root report-overrides &optional isolated-candidate-p) "Prepare NEXT-ROOT for object-scoped TP publication in BUFFER. REPORT-OVERRIDES replaces Ebox semantic report fields. The returned plist contains the isolated candidate root, Ebox node identities, report, and -runtime overrides; the surface layer resolves identities to opaque TP objects." +runtime overrides; the surface layer resolves identities to opaque TP objects. +When ISOLATED-CANDIDATE-P is non-nil, NEXT-ROOT is an internally copied +runtime candidate whose identities already match the published root." (let ((old-state (ebox--buffer-render-state buffer))) (unless old-state (error "Ebox buffer has no rendered runtime: %S" buffer)) - (let* ((prepared - (ebox-incremental--prepare-declarative-root - buffer old-state next-root)) - (candidate-state - (ebox-incremental--candidate-state - old-state (plist-get prepared :root) - (plist-get prepared :index) prepared)) - (owner-plan - (ebox-incremental--layout-owner-plan - buffer old-state candidate-state - (plist-get prepared :dirty-set))) - (scope-node-ids - (delete-dups - (or (mapcar (lambda (op) (plist-get op :owner-id)) owner-plan) - (mapcar (lambda (entry) (plist-get entry :node-id)) - (plist-get prepared :dirty-set))))) - (report - (ebox-incremental--plist-overlay - (ebox-incremental--layout-owner-report - prepared owner-plan candidate-state) - report-overrides))) - (list :root (plist-get prepared :root) - :scope-node-ids scope-node-ids - :report-base report - :state-overrides - (list :viewport-width (plist-get old-state :viewport-width) - :viewport-height (plist-get old-state :viewport-height) - :display-signature - (plist-get prepared :display-signature) - :scroll-state-table - (plist-get prepared :scroll-state-table) - :detached-identity-history - (plist-get prepared :detached-identity-history)))))) + (let* ((candidate + (ebox-incremental--prepare-scoped-candidate + buffer old-state next-root isolated-candidate-p))) + ;; A path-copied candidate is safe to render in place only after the + ;; strict span proof succeeds. Widened projection would mutate shared + ;; layout nodes, so promote it to a private copy before the surface sees + ;; it. The ordinary full-copy path remains the correctness fallback. + (when (and (plist-get candidate :path-copied-p) + (not (eq (plist-get candidate :projection-kind) + 'span-patch))) + (setq candidate + (ebox-incremental--prepare-scoped-candidate + buffer old-state + (ebox-tree-copy-node-structure + (plist-get (plist-get candidate :prepared) :root)) + isolated-candidate-p))) + (let* ((prepared (plist-get candidate :prepared)) + (candidate-state (plist-get candidate :candidate-state)) + (owner-plan (plist-get candidate :owner-plan)) + (scope-node-ids (plist-get candidate :scope-node-ids)) + (span-proof (plist-get candidate :span-proof)) + (projection-kind (plist-get candidate :projection-kind)) + (report + (ebox-incremental--plist-overlay + (ebox-incremental--layout-owner-report + prepared owner-plan candidate-state) + report-overrides))) + (list :root (plist-get prepared :root) + :scope-node-ids scope-node-ids + :report-base report + :projection-kind projection-kind + :preserve-identities-p t + :state-overrides + (ebox-incremental--surface-state-overrides + old-state prepared candidate-state projection-kind span-proof + (plist-get candidate :path-copied-p))))))) (defun ebox-incremental--viewport-state-overrides (state) "Return surface overrides from viewport candidate STATE." @@ -5787,6 +6153,9 @@ runtime overrides; the surface layer resolves identities to opaque TP objects." :viewport-height (plist-get state :viewport-height) :display-signature (plist-get state :display-signature) :render-cache (plist-get state :render-cache) + :layout-fragments (plist-get state :layout-fragments) + :layout-fragments-reuse-p + (plist-get state :layout-fragments-reuse-p) :render-signature-cache (plist-get state :render-signature-cache) :flex-content-min-widths (plist-get state :flex-content-min-widths) :viewport-height-dependent-subtree-cache @@ -5797,10 +6166,165 @@ runtime overrides; the surface layer resolves identities to opaque TP objects." (plist-get state :viewport-dependent-node-ids) :viewport-dependent-node-id-axes (plist-get state :viewport-dependent-node-id-axes) + ;; A viewport commit never changes the declarative node topology. + ;; Preserve the prepared indexes so the surface layer does not walk + ;; the complete runtime tree again after rendering the new geometry. + :node-table (plist-get state :node-table) + :parent-table (plist-get state :parent-table) + :region-id-set (plist-get state :region-id-set) + :region-node-table (plist-get state :region-node-table) + :region-box-count-table (plist-get state :region-box-count-table) + :host-ref-table (plist-get state :host-ref-table) + :selector-id-table (plist-get state :selector-id-table) + :selector-class-table (plist-get state :selector-class-table) + :selector-type-table (plist-get state :selector-type-table) + :runtime-type-count-table (plist-get state :runtime-type-count-table) + :native-node-postorder (plist-get state :native-node-postorder) + :runtime-index-prepared-p t :scroll-state-table (plist-get state :scroll-state-table) :detached-identity-history (plist-get state :detached-identity-history))) +(defun ebox-incremental--viewport-root-proof-p + (old-state candidate-state dirty-set owner-plan) + "Return non-nil when a retained viewport root proof can begin." + (let* ((old-root (plist-get old-state :root-node)) + (candidate-root (plist-get candidate-state :root-node)) + (root-id (and old-root (plist-get old-root :node-id))) + (operation (and (= (length owner-plan) 1) (car owner-plan))) + (objects (plist-get old-state :surface-node-object-table))) + (and old-root candidate-root + (eq old-root candidate-root) + (eq (plist-get operation :op) 'owner-rerender) + (equal (plist-get operation :owner-id) root-id) + (cl-every (lambda (dirty) + (and (eq (plist-get dirty :dirty-kind) 'geometry) + (not (plist-get dirty :children)))) + dirty-set) + (not (plist-get old-state :cascade-active-p)) + (not (plist-get old-state :cascade-required-p)) + (not (ebox-style-cascade-active-p)) + (not (ebox-tree-node-visible-overflow-p old-root)) + (hash-table-p objects) + (> (hash-table-count objects) 0) + (ebox-incremental--hash-key-set-equal-p + (plist-get old-state :node-table) + (plist-get candidate-state :node-table)) + (ebox-incremental--hash-key-set-equal-p + (plist-get old-state :region-id-set) + (plist-get candidate-state :region-id-set)) + (ebox-incremental--hash-key-set-equal-p + (plist-get old-state :parent-table) + (plist-get candidate-state :parent-table))))) + +(defun ebox-incremental--viewport-reflow-safe-p + (old-state candidate-state dirty-set owner-plan) + "Return non-nil when a viewport reflow can reuse the retained node tree. +This strict projection retains only viewport-independent scroll producers." + (and (ebox-incremental--viewport-root-proof-p + old-state candidate-state dirty-set owner-plan) + (ebox-incremental--stable-scroll-state-p old-state))) + +(defun ebox-incremental--stable-scroll-state-p (state) + "Return non-nil when STATE's scroll producers are safe to retain. +The proof is local to each scroll owner: its measured subtree must be +independent of both viewport axes, must not spill visible overflow, and must +have a published scroll state. A changing ancestor position is harmless; +the producer's content geometry is the retained boundary." + (let ((scroll-ids (plist-get state :scroll-region-ids)) + (region-node-table (plist-get state :region-node-table)) + (node-table (plist-get state :node-table)) + (scroll-state-table (plist-get state :scroll-state-table))) + (or (null scroll-ids) + (and (hash-table-p region-node-table) + (hash-table-p node-table) + (hash-table-p scroll-state-table) + (cl-every + (lambda (region-id) + (let* ((node-id (gethash region-id region-node-table)) + (node (and node-id (gethash node-id node-table)))) + (and node + (gethash region-id scroll-state-table) + (not (ebox--viewport-dependent-subtree-p node)) + (not (ebox--viewport-height-dependent-subtree-p node)) + (not (ebox-tree-node-visible-overflow-p node))))) + scroll-ids))))) + +(defun ebox-incremental--viewport-scroll-partition (state) + "Partition STATE scroll owners into stable and viewport-affected sets. +A producer is stable only when its own subtree is independent of both +viewport axes and has no visible overflow; an affected producer is rendered +fresh by the mixed projection." + (let ((region-node-table (plist-get state :region-node-table)) + (node-table (plist-get state :node-table)) + (scroll-state-table (plist-get state :scroll-state-table)) + (stable nil) + (affected nil)) + (when (and (hash-table-p region-node-table) + (hash-table-p node-table) + (hash-table-p scroll-state-table)) + (let ((ebox--viewport-dependent-node-ids-cache + (make-hash-table :test #'eq)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test #'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test #'eq))) + (dolist (region-id (plist-get state :scroll-region-ids)) + (let* ((node-id (gethash region-id region-node-table)) + (node (and node-id (gethash node-id node-table))) + (scroll-state (gethash region-id scroll-state-table)) + (stable-p + (and node scroll-state + (not (ebox--viewport-dependent-subtree-p node)) + (not (ebox--viewport-height-dependent-subtree-p node)) + (not (ebox-tree-node-visible-overflow-p node))))) + (if stable-p + (push region-id stable) + (push region-id affected))))) + (setq stable (nreverse stable) + affected (nreverse affected)) + (list :stable stable + :affected affected + :all (append stable affected))))) + +(defun ebox-incremental--viewport-mixed-scroll-proof-p + (old-state candidate-state dirty-set owner-plan partition) + "Return PARTITION when a mixed viewport projection is proven safe." + (and (ebox-incremental--viewport-root-proof-p + old-state candidate-state dirty-set owner-plan) + (plist-get partition :stable) + (plist-get partition :affected) + partition)) + +(defun ebox-incremental--viewport-reflow-state-overrides + (old-state candidate-state &optional scroll-partition) + "Return candidate STATE overrides for a retained viewport reflow." + (append + (ebox-incremental--viewport-state-overrides candidate-state) + (list + :preserve-scroll-producer-region-ids + (plist-get scroll-partition :stable) + :scroll-region-ids (plist-get candidate-state :scroll-region-ids) + :node-table (plist-get candidate-state :node-table) + :parent-table (plist-get candidate-state :parent-table) + :region-id-set (plist-get candidate-state :region-id-set) + :region-node-table (plist-get candidate-state :region-node-table) + :region-box-count-table (plist-get candidate-state :region-box-count-table) + :region-box-table (plist-get candidate-state :region-box-table) + :host-ref-table (plist-get candidate-state :host-ref-table) + :selector-id-table (plist-get candidate-state :selector-id-table) + :selector-class-table (plist-get candidate-state :selector-class-table) + :selector-type-table (plist-get candidate-state :selector-type-table) + :runtime-type-count-table + (plist-get candidate-state :runtime-type-count-table) + :selector-index-stale-p + (plist-get candidate-state :selector-index-stale-p) + :native-node-postorder + (plist-get candidate-state :native-node-postorder) + :runtime-index-prepared-p t + :surface-node-object-table + (plist-get old-state :surface-node-object-table)))) + (defun ebox-incremental-prepare-viewport-commit (buffer viewport-width viewport-height axes display-signature) "Prepare BUFFER for VIEWPORT-WIDTH, VIEWPORT-HEIGHT, AXES, and DISPLAY-SIGNATURE." @@ -5839,16 +6363,23 @@ runtime overrides; the surface layer resolves identities to opaque TP objects." (ebox-incremental--candidate-scroll-state-table buffer old-state old-state (plist-get old-state :region-box-table))) - (candidate-state (copy-sequence old-state)) + (candidate-state + (ebox-incremental--isolate-candidate-caches + (copy-sequence old-state))) (prepared (list :root (plist-get old-state :root-node) :dirty-set dirty-set :display-signature display-signature :scroll-state-table candidate-scroll-table)) - owner-plan report) + owner-plan report projection-kind scroll-partition) (plist-put candidate-state :viewport-width viewport-width) (plist-put candidate-state :viewport-height target-height) (plist-put candidate-state :display-signature display-signature) + ;; Fragment reuse is a consequence of the complete viewport-reflow + ;; proof below, not a proxy for "the display did not change". An + ;; owner-rerender can still change intrinsic measurements, scroll + ;; dependencies, and generation-sensitive side effects. + (plist-put candidate-state :layout-fragments-reuse-p nil) (plist-put candidate-state :runtime-revision (1+ (or (plist-get old-state :runtime-revision) 0))) (plist-put candidate-state :region-box-table @@ -5865,6 +6396,32 @@ runtime overrides; the surface layer resolves identities to opaque TP objects." (append (ebox--constraint-change-report-props change) (list :viewport-axes axes :host-context-changed display-changed)))) + (setq projection-kind + (cond + ((and (not display-changed) + (ebox-incremental--viewport-reflow-safe-p + old-state candidate-state dirty-set owner-plan)) + 'viewport-reflow) + ((and (not display-changed) + (setq scroll-partition + (ebox-incremental--viewport-scroll-partition + candidate-state)) + (ebox-incremental--viewport-mixed-scroll-proof-p + old-state candidate-state dirty-set owner-plan + scroll-partition)) + 'viewport-reflow-mixed-scroll))) + (when (memq projection-kind + '(viewport-reflow viewport-reflow-mixed-scroll)) + (plist-put candidate-state :layout-fragments-reuse-p t)) + (when projection-kind + (setq report (plist-put report :projection-kind projection-kind)) + (when scroll-partition + (setq report + (plist-put report :viewport-scroll-stable-ids + (plist-get scroll-partition :stable))) + (setq report + (plist-put report :viewport-scroll-affected-ids + (plist-get scroll-partition :affected))))) (list :root (plist-get candidate-state :root-node) :scope-node-ids (delete-dups @@ -5872,9 +6429,16 @@ runtime overrides; the surface layer resolves identities to opaque TP objects." (mapcar (lambda (entry) (plist-get entry :node-id)) dirty-set))) :report-base report + :projection-kind projection-kind :state-overrides - (ebox-incremental--viewport-state-overrides - candidate-state)))))) + (if (memq projection-kind + '(viewport-reflow viewport-reflow-mixed-scroll)) + (ebox-incremental--viewport-reflow-state-overrides + old-state candidate-state + (and (eq projection-kind 'viewport-reflow-mixed-scroll) + scroll-partition)) + (ebox-incremental--viewport-state-overrides + candidate-state))))))) (defun ebox-incremental--finalize-declarative-scroll-publication (scroll-keys &optional prefetch-delay) diff --git a/ebox-layout.el b/ebox-layout.el index 8c8d490..7367ca7 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -46,6 +46,17 @@ must not stretch their own outer width to that containing block.") (puthash rendered pixel-width ebox--rendered-uniform-width-table)) rendered) +(defun ebox--preformatted-content-lines-fit-p (lines pixel-width) + "Return non-nil when LINES fit the exact content PIXEL-WIDTH. +Blank lines without data-bearing properties are safe because the wrapper +replaces them with its full-width filler line." + (cl-every + (lambda (line) + (or (and (string-blank-p line) + (not (ebox--line-has-non-content-properties-p line))) + (= (ebox--string-pixel-width line) pixel-width))) + lines)) + (defun ebox--every-rendered-line-nonempty-p (rendered) "Return non-nil when every line in RENDERED can carry text properties." (let ((start 0) @@ -163,20 +174,18 @@ the generic box pipeline." (ebox--apply-surface-properties rendered (ebox-get box :surface-properties)) (puthash region-id box ebox--region-box-table) - (ebox--scroll-clear-state region-id) + (unless ebox--intrinsic-layout-measurement + (ebox--scroll-clear-state region-id)) (ebox--record-rendered-uniform-width rendered known-width)))))) (defun ebox--render-flat-preformatted-box (box) - "Render exact preformatted BOX chrome in one line-oriented pass. -Return nil unless BOX is a marginless wrapper whose content already has its -exact width and requires no vertical clipping, padding, or alignment work." + "Render simple BOX chrome in a single line-oriented pass. +Return nil unless BOX has no child layout, no margins or vertical clipping, +and its formatted content exactly fills the used height." (when (and (not ebox--flat-preformatted-box-fast-path-disabled) - (plist-get box :ebox-content-width-exact-p) (not (eq (ebox-get box :visibility) 'hidden)) (null (ebox--box-content-node box)) (stringp (ebox-get box :content)) - (null (ebox-get box :wrap-mode)) - (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top) (equal (or (ebox-get box :min-height) 0) 0) (null (ebox-get box :max-height)) @@ -185,17 +194,19 @@ exact width and requires no vertical clipping, padding, or alignment work." (equal (ebox-get box :margin-right-pixel) 0) (equal (ebox-get box :margin-top-height) 0) (equal (ebox-get box :margin-bottom-height) 0)) - (let* ((content (ebox-get box :content)) - (lines (ebox-string-lines content)) + (let* ((content (ebox--box-content box)) + (exact-p (and (plist-get box :ebox-content-width-exact-p) + (null (ebox-get box :wrap-mode)) + (eq (ebox-get box :text-align) 'left))) + (formatted (if exact-p + content + (ebox--format-content-string box content))) + (lines (ebox-string-lines formatted)) (text-height (length lines)) - (height (ebox-get box :height)) - (overflow (ebox-get box :overflow)) - (exact-fixed-height-p - (and (numberp height) - (eq overflow 'hidden) - (= text-height (ebox--content-height box text-height))))) - (when (or (and (null height) (eq overflow 'scroll)) - exact-fixed-height-p) + (content-height (ebox--content-height box text-height))) + (when (and (= text-height content-height) + (ebox--preformatted-content-lines-fit-p + lines (ebox--content-pixel box))) (let* ((region-id (or ebox--render-region-id (ebox--ensure-region-id box))) (content-pixel (ebox--content-pixel box)) @@ -205,7 +216,8 @@ exact width and requires no vertical clipping, padding, or alignment work." (ebox--window-render-flat-wrapper-chunk-lines box lines 0 region-id padding-line-filler t t)))) (puthash region-id box ebox--region-box-table) - (ebox--scroll-clear-state region-id) + (unless ebox--intrinsic-layout-measurement + (ebox--scroll-clear-state region-id)) (ebox--record-rendered-uniform-width rendered (+ content-pixel (ebox--side-pixel box)))))))) @@ -239,6 +251,15 @@ exact width and requires no vertical clipping, padding, or alignment work." (defvar ebox--render-cache-allow-viewport-dependent) (defvar ebox--render-cache-signature-cache) (defvar ebox--render-cache-table) +(defvar ebox--render-cache-scroll-state-region-ids) +(defvar ebox--render-cache-scroll-state-restorable-p) +(defvar ebox--render-cache-scroll-state-retained-cost-cache) + +(declare-function ebox-style-cascade-active-p "ebox-style" ()) +(declare-function ebox-surface--candidate-root "ebox-surface" + (source preserve-identities-p)) +(declare-function ebox-surface--inline-inheritance-required-p "ebox-surface" + (root)) (defun ebox--register-window-line-renderer (type function) "Register FUNCTION as TYPE's lazy window-line renderer. @@ -787,24 +808,40 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (defun ebox--render-box-content-node (box node) "Render NODE as BOX content, using the render-pass cache when available." - (if (null ebox--box-content-render-cache) - (ebox--render-node-as-box-content node box) - (let* ((context (ebox--box-content-cache-context)) - (entries (gethash box ebox--box-content-render-cache)) - (cached (assoc context entries))) - (if cached - (cdr cached) - (let ((content (ebox--render-node-as-box-content node box))) - (puthash box - (cons (cons context content) entries) - ebox--box-content-render-cache) - content))))) + (let* ((content-viewport (ebox--wrapper-content-viewport-pixel box)) + (content + (if (null ebox--box-content-render-cache) + (ebox--render-node-as-box-content node box) + (let* ((context (ebox--box-content-cache-context)) + (entries (gethash box ebox--box-content-render-cache)) + (cached (assoc context entries))) + (if cached + (cdr cached) + (let ((rendered + (ebox--render-node-as-box-content node box))) + (puthash box + (cons (cons context rendered) entries) + ebox--box-content-render-cache) + rendered)))))) + ;; A child layout has already resolved wrapping, alignment, and overflow + ;; against CONTENT-VIEWPORT. Preserve that fact on the owning box so the + ;; generic string formatter does not traverse the same propertized output + ;; a second time. Intrinsic measurement is deliberately excluded: it + ;; produces a natural-width probe, not final box content. + (when (and content-viewport + (not (eq (and (listp node) (plist-get node :ebox-type)) + 'box)) + (not ebox--intrinsic-layout-measurement)) + (plist-put box :ebox-content-width-exact-p t)) + content)) (defun ebox--box-content (box) "Return BOX content, rendering any lazy child layout content if present." - (or (when-let ((node (ebox--box-content-node box))) - (ebox--render-box-content-node box node)) - (ebox-get box :content))) + (ebox--propertize-typography + (or (when-let ((node (ebox--box-content-node box))) + (ebox--render-box-content-node box node)) + (ebox-get box :content)) + box)) (defun ebox--line-min-content-pixel (line) "Return LINE's min-content width in content pixels." @@ -923,12 +960,29 @@ an inherited viewport." (let ((content-viewport (ebox--wrapper-content-viewport-pixel props))) (cond ((eq (ebox-get props :width) 'max-content) - (let ((ebox-viewport-width nil) + (let ((ebox-viewport-width nil) (ebox--intrinsic-layout-measurement t)) (ebox-render node))) (content-viewport (let ((ebox-viewport-width content-viewport)) - (ebox-render node))) + (if (and (hash-table-p ebox--render-cache-table) + (not ebox--intrinsic-layout-measurement) + (null ebox--render-region-id) + (fboundp 'ebox--render-cache-probe) + (fboundp 'ebox--render-with-cache)) + (ebox--render-with-cache + node nil + (ebox--render-cache-probe + node nil + (list :box-content t + :content-viewport content-viewport + :viewport-height ebox-viewport-height + :intrinsic ebox--intrinsic-layout-measurement + :render-region-id ebox--render-region-id + :scroll-lookahead + ebox--scroll-window-initial-lookahead-lines-override + :scroll-disabled ebox--scroll-window-render-disabled))) + (ebox-render node)))) (t (ebox-render node))))) @@ -1377,11 +1431,14 @@ Internal implementation of `ebox-render' for box nodes." (overflow-text (mapconcat (lambda (line) - (concat overflow-left-pad - (if color - (ebox--propertize-color line color) - line) - overflow-right-pad)) + (let ((line (if color + (ebox--propertize-color line color) + (copy-sequence line)))) + (when (> (length line) 0) + (put-text-property + 0 (length line) 'ebox-overflow-foreground-source + region-id line)) + (concat overflow-left-pad line overflow-right-pad))) overflow-lines "\n"))) (setq result (concat result "\n" overflow-text)))) @@ -1427,9 +1484,37 @@ This function owns spatial layout only; it never creates a live TP surface." (declare-function ebox-surface-producer "ebox-surface" (source &optional previous-state preserve-identities-p - state-overrides)) + state-overrides signals projection-kind + source-isolated-p source-path-copied-p)) (declare-function tp-surface-materialize-string "tp-surface" (plan-or-producer)) +(defun ebox--render-ephemeral-static (node) + "Render static NODE without constructing a TP object tree. +The source remains caller-owned; the candidate copy carries all ephemeral +region/node identities and render side tables for this one materialization." + (let ((candidate (ebox-surface--candidate-root node t))) + (let ((ebox--surface-materialization-active t) + (ebox--region-box-table (make-hash-table :test 'equal)) + (ebox--scroll-global-state (make-hash-table :test 'equal)) + (ebox--scroll-idle-prefetch-timers (make-hash-table :test 'equal)) + (ebox--smooth-scroll-state-table (make-hash-table :test 'equal)) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--box-content-render-cache (make-hash-table :test 'eq)) + (ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq)) + (ebox--flex-content-min-width-table (make-hash-table :test 'eq)) + (ebox--render-runtime-revision nil) + (ebox--render-cache-allow-viewport-dependent nil) + (ebox--render-cache-scroll-state-region-ids nil) + (ebox--render-cache-scroll-state-restorable-p nil) + (ebox--render-cache-scroll-state-retained-cost-cache nil)) + (ebox--render-layout candidate)))) + ;;;###autoload (defun ebox-render (node) "Render layout NODE to a multi-line string through a TP materialization. @@ -1439,18 +1524,27 @@ materialized surface is ephemeral and creates no live buffer state." ebox--render-runtime-revision) (ebox--render-layout node) (require 'ebox-surface) - (let ((ebox--surface-materialization-active t) - (ebox--region-id-counter ebox--region-id-counter) - (ebox--runtime-node-id-counter ebox--runtime-node-id-counter)) - (tp-surface-materialize-string - (ebox-surface-producer node nil t))))) + (if (and (not (stringp node)) + (not (ebox-style-cascade-active-p)) + (not (ebox-surface--inline-inheritance-required-p node))) + (let ((ebox--region-id-counter ebox--region-id-counter) + (ebox--runtime-node-id-counter ebox--runtime-node-id-counter)) + (ebox--render-ephemeral-static node)) + (let ((ebox--surface-materialization-active t) + (ebox--region-id-counter ebox--region-id-counter) + (ebox--runtime-node-id-counter ebox--runtime-node-id-counter)) + (tp-surface-materialize-string + (ebox-surface-producer node nil t)))))) (defun ebox--width-pad (string extra-pixels) "Append a display-space of EXTRA-PIXELS to the right of every line in STRING. Used by `ebox--render-stack' to equalise rows to the same total width." (if (<= extra-pixels 0) string - (let ((pad (propertize " " 'display `(space :width (,extra-pixels))))) + (let* ((display + (ebox--register-render-owned-text-value + 'display `(space :width (,extra-pixels)))) + (pad (propertize " " 'display display))) (ebox--maplines (lambda (line) (concat line pad)) string)))) (defun ebox--height-pad (string target-height) diff --git a/ebox-measure.el b/ebox-measure.el index d18782e..0cbd632 100644 --- a/ebox-measure.el +++ b/ebox-measure.el @@ -9,6 +9,9 @@ (require 'cl-lib) (require 'ebox-cache) +(declare-function ebox--register-render-owned-text-value + "ebox-render-context" (property value)) + ;; Measurement Cache Model: ;; These caches only accelerate display-signature-sensitive pixel measurement. ;; They must not carry layout identity, dirty state, or buffer ownership. @@ -257,7 +260,9 @@ Nested uses reuse the outer validation." `(if ebox--display-cache-validated (progn ,@body) (let ((ebox--display-cache-validated t) - (ebox--render-display-signature (ebox--display-signature))) + (ebox--render-display-signature + (or ebox--render-display-signature + (ebox--display-signature)))) (let ((ebox--render-string-pixel-width-cache (make-hash-table :test 'equal)) (ebox--render-string-max-pixel-width-cache @@ -278,7 +283,7 @@ Nested uses reuse the outer validation." "Return STRING width from START to END, preserving text properties." (ebox--string-pixel-width (substring string start end))) -(defsubst ebox--space-pixel-width () +(defun ebox--space-pixel-width () "Get the pixel width of a space character, using cache." (ebox--ensure-display-cache-current) (or ebox--space-pixel-cache @@ -303,7 +308,10 @@ Nested uses reuse the outer validation." "Generate a space string with PIXEL-WIDTH." (if (or (null pixel-width) (<= pixel-width 0)) "" - (propertize " " 'display `(space :width (,pixel-width))))) + (let ((display + (ebox--register-render-owned-text-value + 'display `(space :width (,pixel-width))))) + (propertize " " 'display display)))) (defun ebox--pixel-blank (pixel-width height) "Generate a blank area of PIXEL-WIDTH and HEIGHT lines." diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index c5cdc04..5c7f514 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -28,6 +28,8 @@ (session generation key)) (declare-function ebox-native--module-render-proof "ebox_native_reflow" (payload)) +(declare-function ebox-native--module-flex-size-lines "ebox_native_reflow" + (payload main-limit main-gap)) (declare-function ebox-native--module-confirm-frame "ebox_native_reflow" (session generation key confirmed-revision)) (declare-function ebox-native--module-attach-readiness-channel @@ -67,7 +69,7 @@ (defvar ebox--flex-content-min-width-table) -(defconst ebox-native-reflow-abi-version "7:2:9" +(defconst ebox-native-reflow-abi-version "8:3:9" "Version tuple shared by the native module, layout IR, and render tape.") (defconst ebox-native-reflow--minimum-rust-version "1.82.0" @@ -175,6 +177,9 @@ module while loading the package." (defvar ebox-native-reflow--last-build-report nil "Result plist from the most recent `ebox-native-build' invocation.") +(defvar ebox-native-reflow--flex-geometry-call-count 0 + "Number of pure native Flex geometry batches executed in this Emacs.") + (defvar ebox-native-reflow--build-process nil "Live asynchronous process started by `ebox-native-build', or nil.") @@ -1189,6 +1194,78 @@ Build output and exact errors appear in `*Ebox Native Build*'." :loaded-module-hash ebox-native-reflow--loaded-module-hash :load-error ebox-native-reflow--load-error))) +(defun ebox-native-reflow--flex-size-payload (lines) + "Encode normalized flex LINES for the pure native geometry kernel." + (let ((payload (list (length lines)))) + (dolist (line lines) + (setq payload (nconc payload (list (length line)))) + (dolist (item line) + (let* ((props (plist-get item :props)) + (base (plist-get item :base)) + (hypothetical (plist-get item :hypothetical)) + (min-main (plist-get item :min-main)) + (max-main (plist-get item :max-main))) + (setq payload + (nconc + payload + (list (truncate base) + (truncate hypothetical) + (truncate (or min-main 0)) + (and (numberp max-main) (truncate max-main)) + (float (or (plist-get props :flex-grow) 0)) + (float (or (plist-get props :flex-shrink) 0)))))))) + (vconcat payload))) + +(defun ebox-native-reflow--read-flex-i64 (bytes position) + "Read one nonnegative little-endian integer from BYTES at POSITION." + (let ((value 0)) + (cl-loop for byte-offset from 0 below 8 + do + (setq value + (logior value + (ash (aref bytes (+ position byte-offset)) + (* 8 byte-offset)))) + finally return value))) + +(defun ebox-native-reflow-flex-size-lines (lines main-limit main-gap) + "Return LINES with native target sizes, or nil when unsupported. +The native side receives only measured numeric geometry. It never receives +Ebox nodes, identities, text, properties, or publication state." + (when (and (fboundp 'ebox-native--module-flex-size-lines) + (integerp main-limit) + (integerp main-gap) + (cl-every + (lambda (line) + (cl-every + (lambda (item) + (and (numberp (plist-get item :base)) + (numberp (plist-get item :hypothetical)) + (numberp (plist-get item :min-main)) + (let ((max-main (plist-get item :max-main))) + (or (null max-main) (numberp max-main))))) + line)) + lines)) + (cl-incf ebox-native-reflow--flex-geometry-call-count) + (let* ((payload (ebox-native-reflow--flex-size-payload lines)) + (bytes + (ebox-native--module-flex-size-lines + payload main-limit main-gap)) + (expected-bytes + (* 8 (apply #'+ (mapcar #'length lines))))) + (when (and (stringp bytes) (= (length bytes) expected-bytes)) + (let ((position 0) + result) + (dolist (line lines (nreverse result)) + (let (sized) + (dolist (item line) + (let ((copy (copy-sequence item))) + (plist-put + copy :target + (ebox-native-reflow--read-flex-i64 bytes position)) + (setq position (+ position 8)) + (push copy sized))) + (push (nreverse sized) result)))))))) + (cl-defun ebox-native-reflow-create-session (&key workers (max-jobs ebox-native-reflow-max-jobs) diff --git a/ebox-render-context.el b/ebox-render-context.el index 3ac9f71..0bc81fe 100644 --- a/ebox-render-context.el +++ b/ebox-render-context.el @@ -19,6 +19,98 @@ (defvar ebox--flex-content-min-width-table nil "Render-owned box measurements reusable by native scene compilation.") +(defvar ebox--render-owned-text-values nil + "Candidate-local text-property values explicitly created by Ebox.") + +(defvar ebox--render-output-provenance-table + (make-hash-table :test #'eq :weakness 'key) + "Weak map from rendered strings to Ebox-owned property values.") + +(defun ebox--render-owned-text-values-for (property &optional create) + "Return the active candidate registry for PROPERTY when CREATE is non-nil." + (when (hash-table-p ebox--render-owned-text-values) + (or (gethash property ebox--render-owned-text-values) + (when create + (let ((values (make-hash-table :test #'eq :weakness 'key))) + (puthash property values ebox--render-owned-text-values) + values))))) + +(defun ebox--register-render-owned-text-value (property value) + "Register Ebox-created VALUE for PROPERTY in the active render candidate." + (when value + (when-let ((values (ebox--render-owned-text-values-for property t))) + (puthash value t values))) + value) + +(defun ebox--render-owned-text-value-p (property value &optional registry) + "Return non-nil when VALUE is Ebox-owned for PROPERTY. +REGISTRY defaults to the active render candidate." + (let ((ebox--render-owned-text-values + (or registry ebox--render-owned-text-values))) + (when-let ((values (ebox--render-owned-text-values-for property))) + (gethash value values)))) + +(defun ebox--register-render-owned-face-values (source rendered) + "Register Ebox-generated face identities added to RENDERED from SOURCE." + (when (and (stringp source) + (stringp rendered) + (= (length source) (length rendered)) + (hash-table-p ebox--render-owned-text-values)) + (let ((position 0) + (length (length rendered))) + (while (< position length) + (let ((next + (min (or (next-property-change position source) length) + (or (next-property-change position rendered) length))) + (source-face (get-text-property position 'face source)) + (rendered-face (get-text-property position 'face rendered))) + (when (and rendered-face (null source-face)) + (ebox--register-render-owned-text-value 'face rendered-face)) + (setq position next))))) + rendered) + +(defun ebox--record-render-output-provenance (rendered) + "Record owned property identities actually present in RENDERED." + (when (and (stringp rendered) + (hash-table-p ebox--render-owned-text-values)) + (let ((provenance (make-hash-table :test #'eq)) + (position 0) + (length (length rendered))) + (remhash rendered ebox--render-output-provenance-table) + (while (< position length) + (let ((next (or (next-property-change position rendered) length)) + (properties (text-properties-at position rendered))) + (while properties + (let* ((property (pop properties)) + (value (pop properties)) + (owned (ebox--render-owned-text-value-p property value))) + (when owned + (let ((values (or (gethash property provenance) + (let ((new (make-hash-table + :test #'eq :weakness 'key))) + (puthash property new provenance) + new)))) + (puthash value t values))))) + (setq position next))) + (when (> (hash-table-count provenance) 0) + (puthash rendered provenance ebox--render-output-provenance-table)))) + rendered) + +(defun ebox--replay-render-output-provenance (rendered) + "Replay owned property identities recorded for RENDERED into this candidate." + (when (and (stringp rendered) + (hash-table-p ebox--render-owned-text-values)) + (when-let ((provenance + (gethash rendered ebox--render-output-provenance-table))) + (maphash + (lambda (property values) + (let ((owned (ebox--render-owned-text-values-for property t))) + (maphash (lambda (value _marker) + (puthash value t owned)) + values))) + provenance))) + rendered) + (declare-function ebox--buffer-render-state "ebox-incremental" (buffer)) diff --git a/ebox-selector.el b/ebox-selector.el index d82ea70..af02b02 100644 --- a/ebox-selector.el +++ b/ebox-selector.el @@ -1,17 +1,17 @@ ;;; ebox-selector.el --- CSS-like runtime selectors for Ebox -*- lexical-binding: t; -*- ;;; Commentary: -;; Compiles CSS-like lookup syntax to TP structured selectors and adapts Ebox -;; trees/runtime indexes to TP subjects. Stable identity remains node ids, +;; Delegates CSS-like lookup syntax and matching to ECSS, and adapts Ebox +;; trees/runtime indexes to ECSS subjects. Stable identity remains node ids, ;; region ids, and keys. ;;; Code: (require 'cl-lib) (require 'subr-x) +(require 'ecss-selector) (require 'ebox-buffer-backend) (require 'ebox-tree) -(require 'tp-style) (require 'tp-surface) (declare-function ebox--ensure-node-id "ebox" (node)) @@ -92,161 +92,14 @@ (user-error "Ebox region handle is stale")) (cons buffer region-id))) -(defun ebox-selector--identifier-char-p (char) - "Return non-nil when CHAR is accepted in a selector identifier." - (or (and (>= char ?a) (<= char ?z)) - (and (>= char ?A) (<= char ?Z)) - (and (>= char ?0) (<= char ?9)) - (memq char '(?_ ?- ?:)))) - -(defun ebox-selector--skip-space (selector pos) - "Return the next non-space position in SELECTOR after POS." - (let ((len (length selector))) - (while (and (< pos len) - (memq (aref selector pos) '(?\s ?\t ?\n ?\r))) - (setq pos (1+ pos))) - pos)) - -(defun ebox-selector--read-identifier (selector pos) - "Read one selector identifier from SELECTOR at POS. -Return a cons of (IDENTIFIER . NEXT-POS)." - (let ((start pos) - (len (length selector))) - (while (and (< pos len) - (ebox-selector--identifier-char-p (aref selector pos))) - (setq pos (1+ pos))) - (when (= pos start) - (user-error "ebox-selector: expected identifier at %d in %S" - pos selector)) - (cons (substring selector start pos) pos))) - -(defun ebox-selector--read-attr-value (selector pos) - "Read one attribute value from SELECTOR at POS. -Return a cons of (VALUE . NEXT-POS)." - (let ((len (length selector))) - (if (and (< pos len) (= (aref selector pos) ?\")) - (let ((start (1+ pos))) - (setq pos start) - (while (and (< pos len) - (/= (aref selector pos) ?\")) - (setq pos (1+ pos))) - (when (>= pos len) - (user-error "ebox-selector: unterminated attribute value in %S" - selector)) - (cons (substring selector start pos) (1+ pos))) - (ebox-selector--read-identifier selector pos)))) - -(defun ebox-selector--compound-selector (type id classes attrs) - "Return one TP selector combining TYPE, ID, CLASSES, and ATTRS." - (let ((parts - (append - (when type (list (list :type type))) - (when id (list (list :id id))) - (mapcar (lambda (class) (list :class class)) - (nreverse classes)) - (mapcar (lambda (attr) (list :attr (car attr) (cdr attr))) - (nreverse attrs))))) - (if (= (length parts) 1) - (car parts) - (cons :and parts)))) - -(defun ebox-selector--parse-attribute (selector pos attrs) - "Parse an attribute selector in SELECTOR at POS and push into ATTRS." - (let* ((name-read (ebox-selector--read-identifier selector (1+ pos))) - (name (car name-read)) - (next (cdr name-read))) - (unless (and (< next (length selector)) - (= (aref selector next) ?=)) - (user-error "ebox-selector: expected = after attribute at %d in %S" - next selector)) - (let* ((value-read (ebox-selector--read-attr-value selector (1+ next))) - (value (car value-read)) - (end (cdr value-read))) - (unless (and (< end (length selector)) - (= (aref selector end) ?\])) - (user-error "ebox-selector: expected ] at %d in %S" end selector)) - (list :attrs (cons (cons (intern (concat ":" name)) value) - attrs) - :pos (1+ end))))) - -(defun ebox-selector--parse-simple (selector pos) - "Parse one simple selector from SELECTOR at POS. -Return a cons of (SIMPLE-SELECTOR . NEXT-POS)." - (let ((len (length selector)) - type id classes attrs) - (when (and (< pos len) - (ebox-selector--identifier-char-p (aref selector pos))) - (let ((read (ebox-selector--read-identifier selector pos))) - (setq type (intern (car read)) - pos (cdr read)))) - (while (and (< pos len) - (memq (aref selector pos) '(?. ?# ?\[))) - (pcase (aref selector pos) - (?. - (let ((read (ebox-selector--read-identifier selector (1+ pos)))) - (push (car read) classes) - (setq pos (cdr read)))) - (?# - (let ((read (ebox-selector--read-identifier selector (1+ pos)))) - (setq id (car read) - pos (cdr read)))) - (?\[ - (let* ((parsed (ebox-selector--parse-attribute selector pos attrs))) - (setq attrs (plist-get parsed :attrs) - pos (plist-get parsed :pos)))))) - (unless (or type id classes attrs) - (user-error "ebox-selector: expected selector at %d in %S" - pos selector)) - (cons (ebox-selector--compound-selector type id classes attrs) pos))) - -(defun ebox-selector--combinator (char) - "Return TP combinator for CHAR, or nil when CHAR is not a combinator." - (pcase char - (?> :child) - (?+ :adjacent) - (?~ :sibling) - (_ nil))) - -(defun ebox-selector--combine-sequence (sequence) - "Fold parsed selector SEQUENCE into one nested TP selector." - (let ((result (pop sequence))) - (while sequence - (let ((combinator (pop sequence)) - (target (pop sequence))) - (unless target - (user-error "ebox-selector: combinator %S has no target" combinator)) - (setq result (list combinator result target)))) - result)) - ;;;###autoload (defun ebox-selector-parse (selector) - "Compile CSS-like SELECTOR into TP's structured selector AST." - (unless (and (stringp selector) - (> (length (string-trim selector)) 0)) - (user-error "ebox-selector: selector must be a non-empty string")) - (let* ((pos (ebox-selector--skip-space selector 0)) - (len (length selector)) - sequence) - (while (< pos len) - (let ((read (ebox-selector--parse-simple selector pos))) - (push (car read) sequence) - (setq pos (cdr read))) - (let ((before-space pos)) - (setq pos (ebox-selector--skip-space selector pos)) - (cond - ((>= pos len)) - ((ebox-selector--combinator (aref selector pos)) - (push (ebox-selector--combinator (aref selector pos)) sequence) - (setq pos (ebox-selector--skip-space selector (1+ pos))) - (when (>= pos len) - (user-error "ebox-selector: combinator has no target in %S" - selector))) - ((> pos before-space) - (push :descendant sequence)) - (t - (user-error "ebox-selector: expected combinator at %d in %S" - pos selector))))) - (ebox-selector--combine-sequence (nreverse sequence)))) + "Compile CSS-like SELECTOR through the ECSS parser." + (condition-case error-data + (ecss-selector-parse selector) + (ecss-invalid-selector + (user-error "ebox-selector: invalid selector %S: %S" + selector (cdr error-data))))) (defun ebox-selector--node-region-id (node) "Return NODE's editable box region id, or nil." @@ -264,8 +117,8 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)." ;;;###autoload (defun ebox-selector-match-node-p (node selector) - "Return non-nil when NODE matches structured TP SELECTOR." - (tp-selector-match-p selector (ebox-tree-node-subject node))) + "Return non-nil when NODE matches structured ECSS SELECTOR." + (ecss-selector-match-p selector (ebox-tree-node-subject node))) (defun ebox-selector--match-handle (node path selector) "Return a selector match handle for NODE on PATH." @@ -276,10 +129,10 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)." :selector selector)) (defun ebox-selector--matching-subject-entries (root ast) - "Return semantic ROOT entries whose TP subjects match AST." + "Return semantic ROOT entries whose ECSS subjects match AST." (cl-remove-if-not (lambda (entry) - (tp-selector-match-p ast (plist-get entry :subject))) + (ecss-selector-match-p ast (plist-get entry :subject))) (plist-get (ebox-tree-subject-index root) :entries))) (defun ebox-selector--subject-entry-handle (entry selector) @@ -322,7 +175,8 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)." "Return non-nil when AST contains a selector relationship." (pcase (car-safe ast) ((or :descendant :child :adjacent :sibling) t) - ((or :and :is :where :not) + (:has t) + ((or :and :list :is :where :not) (cl-some #'ebox-selector--relational-p (cdr ast))) (_ nil))) @@ -332,8 +186,8 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)." (:descendant (and (ebox-selector--descendant-indexable-p (nth 1 ast)) (ebox-selector--descendant-indexable-p (nth 2 ast)))) - ((or :child :adjacent :sibling) nil) - ((or :and :is :where :not) + ((or :child :adjacent :sibling :has) nil) + ((or :and :list :is :where :not) (cl-every #'ebox-selector--descendant-indexable-p (cdr ast))) (_ t))) @@ -374,18 +228,19 @@ Return a cons of (SIMPLE-SELECTOR . NEXT-POS)." (push (gethash class (ebox--buffer-selector-class-table buffer)) candidates)) (when type - (push (gethash type (ebox--buffer-selector-type-table buffer)) + (push (gethash (if (stringp type) (intern type) type) + (ebox--buffer-selector-type-table buffer)) candidates)) (when (or id classes type) (cons t (ebox-selector--shortest-candidates candidates))))) (defun ebox-selector--indexed-entry-matches-p (entry ast) - "Return non-nil when indexed ENTRY matches TP selector AST." + "Return non-nil when indexed ENTRY matches ECSS selector AST." (let ((subject (if (ebox-selector--relational-p ast) (ebox-tree-subject-for-path (cdr entry)) (ebox-tree-node-subject (car entry))))) - (tp-selector-match-p ast subject))) + (ecss-selector-match-p ast subject))) (defun ebox-selector--entries-to-buffer-handles (entries selector buffer) "Return selector match handles for ENTRIES annotated with BUFFER." diff --git a/ebox-style.el b/ebox-style.el index cf1c03f..516ce1a 100644 --- a/ebox-style.el +++ b/ebox-style.el @@ -7,99 +7,234 @@ ;;; Code: (require 'cl-lib) +(require 'subr-x) +(require 'ecss) +(require 'tp-style) -(cl-defstruct (ebox-style-property - (:constructor ebox-style-property-create)) - name aliases initial inherited group parser compute dirty-kind signature - backend-mapper) +(defvar ebox--longhand) +(declare-function ebox-selector-parse "ebox-selector" (selector)) +(declare-function ebox-create "ebox" (&rest plist)) +(declare-function ebox--flex-normalize-container-props + "ebox-flex" (props)) (defconst ebox-style--border-styles '(none hidden dotted dashed solid double groove ridge inset outset) "Valid CSS border-style keywords accepted by Ebox style parsing.") -(defun ebox-style--property-definitions () - "Return registered CSS-like property definitions." - (list - (ebox-style-property-create - :name :color :group 'paint :dirty-kind 'paint :signature 'paint) - (ebox-style-property-create - :name :background-color :aliases '(:bgcolor) - :group 'paint :dirty-kind 'paint :signature 'paint) - (ebox-style-property-create - :name :border-color :group 'paint :dirty-kind 'paint :signature 'paint) - (ebox-style-property-create - :name :border-width :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :padding-block-start :aliases '(:padding-top) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :padding-inline-end :aliases '(:padding-right) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :padding-block-end :aliases '(:padding-bottom) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :padding-inline-start :aliases '(:padding-left) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :margin-block-start :aliases '(:margin-top) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :margin-inline-end :aliases '(:margin-right) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :margin-block-end :aliases '(:margin-bottom) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :margin-inline-start :aliases '(:margin-left) - :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :width :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :height :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :min-width :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :max-width :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :display :group 'structure :dirty-kind 'structure - :signature 'structure) - (ebox-style-property-create - :name :gap :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :row-gap :group 'geometry :dirty-kind 'geometry :signature 'layout) - (ebox-style-property-create - :name :column-gap :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :flex-direction :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :flex-wrap :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :justify-content :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :align-items :group 'geometry :dirty-kind 'geometry - :signature 'layout) - (ebox-style-property-create - :name :align-content :group 'geometry :dirty-kind 'geometry - :signature 'layout))) +(defconst ebox-style--default-foreground 'ebox/default-foreground + "Internal paint consequence that blocks an inherited foreground.") + +(defconst ebox-style--property-definitions + '((:name :color :id ebox/color :initial nil :inherits t + :contexts (box) :group paint :dirty-kind paint :signature paint + :normalizer color) + (:name :background-color :id ebox/background-color :aliases (:bgcolor) + :initial nil :contexts (box) :group paint :dirty-kind paint + :signature paint :normalizer color) + (:name :font :id ebox/font :initial nil :inherits t :contexts (box) + :group typography :dirty-kind geometry :signature layout + :validator font) + (:name :font-family :id ebox/font-family :initial nil :inherits t + :contexts (box) :group typography :dirty-kind geometry :signature layout + :validator font-family) + (:name :font-height :id ebox/font-height :aliases (:font-size) + :initial nil :inherits t :contexts (box) :group typography + :dirty-kind geometry :signature layout :validator font-height) + (:name :font-weight :id ebox/font-weight :initial nil :inherits t + :contexts (box) :group typography :dirty-kind geometry :signature layout + :validator font-attribute) + (:name :font-slant :id ebox/font-slant :initial nil :inherits t + :contexts (box) :group typography :dirty-kind geometry :signature layout + :validator font-attribute) + (:name :box-sizing :id ebox/box-sizing :initial border-box + :contexts (box) :group geometry :dirty-kind geometry :signature layout) + (:name :width :id ebox/width :initial nil :contexts (box flex grid) + :group geometry :dirty-kind geometry :signature layout + :validator horizontal-size) + (:name :min-width :id ebox/min-width :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator horizontal-size) + (:name :max-width :id ebox/max-width :initial nil :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator horizontal-size) + (:name :height :id ebox/height :initial nil :contexts (box flex grid) + :group geometry :dirty-kind geometry :signature layout) + (:name :min-height :id ebox/min-height :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :max-height :id ebox/max-height :initial nil :contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :padding-block-start :id ebox/padding-block-start + :aliases (:padding-top :padding-top-height) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :padding-inline-end :id ebox/padding-inline-end + :aliases (:padding-right :padding-right-pixel) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :padding-block-end :id ebox/padding-block-end + :aliases (:padding-bottom :padding-bottom-height) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :padding-inline-start :id ebox/padding-inline-start + :aliases (:padding-left :padding-left-pixel) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-block-start :id ebox/margin-block-start + :aliases (:margin-top :margin-top-height) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-inline-end :id ebox/margin-inline-end + :aliases (:margin-right :margin-right-pixel) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-block-end :id ebox/margin-block-end + :aliases (:margin-bottom :margin-bottom-height) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :margin-inline-start :id ebox/margin-inline-start + :aliases (:margin-left :margin-left-pixel) :initial 0 :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :border-top-width :id ebox/border-top-width + :aliases (:border-top-p) :initial nil :contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :border-right-width :id ebox/border-right-width + :aliases (:border-right-pixel) :initial nil :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :border-bottom-width :id ebox/border-bottom-width + :aliases (:border-bottom-p) :initial nil :contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :border-left-width :id ebox/border-left-width + :aliases (:border-left-pixel) :initial nil :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :border-top-style :id ebox/border-top-style :initial nil + :contexts (box) :group structure :dirty-kind structure :signature layout) + (:name :border-right-style :id ebox/border-right-style :initial nil + :contexts (box) :group structure :dirty-kind structure :signature layout) + (:name :border-bottom-style :id ebox/border-bottom-style :initial nil + :contexts (box) :group structure :dirty-kind structure :signature layout) + (:name :border-left-style :id ebox/border-left-style :initial nil + :contexts (box) :group structure :dirty-kind structure :signature layout) + (:name :border-top-color :id ebox/border-top-color :initial nil + :contexts (box) :group paint :dirty-kind paint :signature paint + :normalizer color) + (:name :border-right-color :id ebox/border-right-color :initial nil + :contexts (box) :group paint :dirty-kind paint :signature paint + :normalizer color) + (:name :border-bottom-color :id ebox/border-bottom-color :initial nil + :contexts (box) :group paint :dirty-kind paint :signature paint + :normalizer color) + (:name :border-left-color :id ebox/border-left-color :initial nil + :contexts (box) :group paint :dirty-kind paint :signature paint + :normalizer color) + (:name :text-align :id ebox/text-align :initial left :contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :vertical-align :id ebox/vertical-align :initial top :contexts (box) + :group geometry :dirty-kind geometry :signature layout + :normalizer vertical-align) + (:name :overflow :id ebox/overflow :initial scroll :contexts (box) + :group structure :dirty-kind geometry :signature layout) + (:name :wrap-mode :id ebox/wrap-mode :initial word :contexts (box) + :group geometry :dirty-kind geometry :signature layout) + (:name :visibility :id ebox/visibility :initial visible :contexts (box) + :group paint :dirty-kind paint :signature paint) + (:name :display :id ebox/display :initial nil :contexts (node) + :group structure :dirty-kind structure :signature structure + :validator display) + (:name :flex-direction :id ebox/flex-direction :initial nil + :contexts (flex) :group geometry :dirty-kind geometry :signature layout) + (:name :flex-wrap :id ebox/flex-wrap :initial nil :contexts (flex) + :group geometry :dirty-kind geometry :signature layout) + (:name :justify-content :id ebox/justify-content :initial nil + :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) + (:name :align-items :id ebox/align-items :initial nil + :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) + (:name :align-content :id ebox/align-content :initial nil + :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout) + (:name :justify-items :id ebox/justify-items :initial nil + :contexts (grid) :group geometry :dirty-kind geometry :signature layout) + (:name :row-gap :id ebox/row-gap :aliases (:grid-row-gap) :initial nil + :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-size) + (:name :column-gap :id ebox/column-gap :aliases (:grid-column-gap) + :initial nil :contexts (flex grid) :group geometry :dirty-kind geometry + :signature layout :validator nonnegative-size) + (:name :order :id ebox/order :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout) + (:name :flex-grow :id ebox/flex-grow :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-number) + (:name :flex-shrink :id ebox/flex-shrink :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator nonnegative-number) + (:name :flex-basis :id ebox/flex-basis :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator horizontal-size) + (:name :align-self :id ebox/align-self :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout) + (:name :grid-template-columns :id ebox/grid-template-columns :initial nil + :contexts (grid) :group geometry :dirty-kind geometry :signature layout) + (:name :grid-template-rows :id ebox/grid-template-rows :initial nil + :contexts (grid) :group geometry :dirty-kind geometry :signature layout) + (:name :grid-auto-columns :id ebox/grid-auto-columns :initial nil + :contexts (grid) :group geometry :dirty-kind geometry :signature layout) + (:name :grid-auto-rows :id ebox/grid-auto-rows :initial nil + :contexts (grid) :group geometry :dirty-kind geometry :signature layout) + (:name :grid-auto-flow :id ebox/grid-auto-flow :initial nil + :contexts (grid) :group structure :dirty-kind structure :signature layout) + (:name :grid-column :id ebox/grid-column :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator grid-placement) + (:name :grid-row :id ebox/grid-row :initial nil :contexts (item) + :group geometry :dirty-kind geometry :signature layout + :validator grid-placement) + (:name :grid-column-span :id ebox/grid-column-span :initial nil + :contexts (item) :group geometry :dirty-kind geometry :signature layout + :validator positive-integer) + (:name :grid-row-span :id ebox/grid-row-span :initial nil + :contexts (item) :group geometry :dirty-kind geometry :signature layout + :validator positive-integer) + (:name :padding :id ebox/padding :shorthand padding) + (:name :padding-inline :id ebox/padding-inline :shorthand padding-inline) + (:name :padding-block :id ebox/padding-block :shorthand padding-block) + (:name :margin :id ebox/margin :shorthand margin) + (:name :margin-inline :id ebox/margin-inline :shorthand margin-inline) + (:name :margin-block :id ebox/margin-block :shorthand margin-block) + (:name :border :id ebox/border :shorthand border) + (:name :border-top :id ebox/border-top :shorthand border-top) + (:name :border-right :id ebox/border-right :shorthand border-right) + (:name :border-bottom :id ebox/border-bottom :shorthand border-bottom) + (:name :border-left :id ebox/border-left :shorthand border-left) + (:name :border-width :id ebox/border-width :shorthand border-width + :group geometry :dirty-kind geometry :signature layout) + (:name :border-style :id ebox/border-style :shorthand border-style + :group structure :dirty-kind structure :signature layout) + (:name :border-color :id ebox/border-color :shorthand border-color + :group paint :dirty-kind paint :signature paint) + (:name :gap :id ebox/gap :shorthand gap + :group geometry :dirty-kind geometry :signature layout) + (:name :flex-flow :id ebox/flex-flow :shorthand flex-flow + :group geometry :dirty-kind geometry :signature layout) + (:name :flex :id ebox/flex :shorthand flex + :group geometry :dirty-kind geometry :signature layout)) + "Canonical Ebox property schemas and public aliases.") (defvar ebox-style--property-table nil "Hash table from canonical property names and aliases to metadata.") +(defvar ebox-style-schemas (ecss-schema-set-create) + "ECSS property schemas for the isolated Ebox style domain.") + (defun ebox-style--ensure-property-table () "Return the canonical style property registry table." (or ebox-style--property-table (let ((table (make-hash-table :test 'eq))) - (dolist (property (ebox-style--property-definitions)) - (puthash (ebox-style-property-name property) property table) - (dolist (alias (ebox-style-property-aliases property)) + (dolist (property ebox-style--property-definitions) + (puthash (plist-get property :name) property table) + (puthash (plist-get property :id) property table) + (dolist (alias (plist-get property :aliases)) (puthash alias property table))) (setq ebox-style--property-table table)))) @@ -110,7 +245,12 @@ (defun ebox-style-canonical-name (name) "Return canonical CSS-like longhand property name for NAME." (when-let ((property (ebox-style-property name))) - (ebox-style-property-name property))) + (plist-get property :name))) + +(defun ebox-style-schema-id (name) + "Return namespaced ECSS schema id for Ebox property NAME." + (when-let ((property (ebox-style-property name))) + (plist-get property :id))) (defun ebox-style--put (plist key value) "Return PLIST with KEY set to VALUE." @@ -203,88 +343,635 @@ "unspecified-bg")) resolved))) -(defun ebox-style--expand-canonical-property (property value) - "Return canonical CSS-like longhand entries for PROPERTY and VALUE." - (pcase property - (:bgcolor (list :background-color (ebox-style--parse-color value))) - (:background-color - (list :background-color (ebox-style--parse-color value))) - (:color (list :color (ebox-style--parse-color value))) - (:padding - (pcase-let ((`(,top ,right ,bottom ,left) - (ebox-style--split-trbl value))) - (list :padding-block-start top - :padding-inline-end right - :padding-block-end bottom - :padding-inline-start left))) - (:padding-inline - (pcase-let ((`(,start ,end) (ebox-style--split-pair value))) - (list :padding-inline-start start :padding-inline-end end))) - (:padding-block - (pcase-let ((`(,start ,end) (ebox-style--split-pair value))) - (list :padding-block-start start :padding-block-end end))) - (:padding-top (list :padding-block-start value)) - (:padding-right (list :padding-inline-end value)) - (:padding-bottom (list :padding-block-end value)) - (:padding-left (list :padding-inline-start value)) - (:margin - (pcase-let ((`(,top ,right ,bottom ,left) - (ebox-style--split-trbl value))) - (list :margin-block-start top - :margin-inline-end right - :margin-block-end bottom - :margin-inline-start left))) - (:margin-inline - (pcase-let ((`(,start ,end) (ebox-style--split-pair value))) - (list :margin-inline-start start :margin-inline-end end))) - (:margin-block - (pcase-let ((`(,start ,end) (ebox-style--split-pair value))) - (list :margin-block-start start :margin-block-end end))) - (:margin-top (list :margin-block-start value)) - (:margin-right (list :margin-inline-end value)) - (:margin-bottom (list :margin-block-end value)) - (:margin-left (list :margin-inline-start value)) - (:border - (pcase-let ((`(,width ,style ,color) (ebox-style--split-wsc value))) - (append (when width (list :border-width width)) - (when style (list :border-style style)) - (when color - (list :border-color (ebox-style--parse-color color)))))) - (:border-color - (list :border-color (ebox-style--parse-color value))) - (_ - (if-let ((canonical (ebox-style-canonical-name property))) - (list canonical value) - nil)))) +(defun ebox-style--trbl-declarations (properties value) + "Expand VALUE across four namespaced PROPERTIES." + (cl-loop for property in properties + for part in (ebox-style--split-trbl value) + append (list property part))) -(defun ebox-style-expand-shorthands (plist) - "Expand PLIST to CSS-like canonical longhand properties." - (let (expanded) - (while plist - (let ((property (pop plist)) - (value (pop plist))) - (setq expanded - (nconc expanded - (ebox-style--expand-canonical-property - property value))))) - expanded)) +(defun ebox-style--pair-declarations (properties value) + "Expand VALUE across two namespaced PROPERTIES." + (cl-loop for property in properties + for part in (ebox-style--split-pair value) + append (list property part))) + +(defun ebox-style--padding-shorthand (value) + "Expand padding VALUE to namespaced logical longhands." + (ebox-style--trbl-declarations + '(ebox/padding-block-start ebox/padding-inline-end + ebox/padding-block-end ebox/padding-inline-start) + value)) + +(defun ebox-style--padding-inline-shorthand (value) + "Expand inline padding VALUE to namespaced longhands." + (ebox-style--pair-declarations + '(ebox/padding-inline-start ebox/padding-inline-end) value)) + +(defun ebox-style--padding-block-shorthand (value) + "Expand block padding VALUE to namespaced longhands." + (ebox-style--pair-declarations + '(ebox/padding-block-start ebox/padding-block-end) value)) + +(defun ebox-style--margin-shorthand (value) + "Expand margin VALUE to namespaced logical longhands." + (ebox-style--trbl-declarations + '(ebox/margin-block-start ebox/margin-inline-end + ebox/margin-block-end ebox/margin-inline-start) + value)) + +(defun ebox-style--margin-inline-shorthand (value) + "Expand inline margin VALUE to namespaced longhands." + (ebox-style--pair-declarations + '(ebox/margin-inline-start ebox/margin-inline-end) value)) + +(defun ebox-style--margin-block-shorthand (value) + "Expand block margin VALUE to namespaced longhands." + (ebox-style--pair-declarations + '(ebox/margin-block-start ebox/margin-block-end) value)) + +(defconst ebox-style--border-sides '(top right bottom left) + "Physical border sides in CSS shorthand order.") + +(defun ebox-style--border-side-declarations (side value) + "Expand border VALUE for SIDE to three namespaced longhands." + (pcase-let ((`(,width ,style ,color) (ebox-style--split-wsc value))) + (list (intern (format "ebox/border-%s-width" side)) width + (intern (format "ebox/border-%s-style" side)) style + (intern (format "ebox/border-%s-color" side)) color))) + +(defun ebox-style--border-shorthand (value) + "Expand border VALUE directly to all namespaced side longhands." + (cl-mapcan (lambda (side) + (ebox-style--border-side-declarations side value)) + ebox-style--border-sides)) + +(defun ebox-style--border-top-shorthand (value) + "Expand top border VALUE to namespaced longhands." + (ebox-style--border-side-declarations 'top value)) + +(defun ebox-style--border-right-shorthand (value) + "Expand right border VALUE to namespaced longhands." + (ebox-style--border-side-declarations 'right value)) + +(defun ebox-style--border-bottom-shorthand (value) + "Expand bottom border VALUE to namespaced longhands." + (ebox-style--border-side-declarations 'bottom value)) + +(defun ebox-style--border-left-shorthand (value) + "Expand left border VALUE to namespaced longhands." + (ebox-style--border-side-declarations 'left value)) + +(defun ebox-style--border-component-shorthand (component value) + "Expand border COMPONENT VALUE across all physical sides." + (ebox-style--trbl-declarations + (mapcar (lambda (side) + (intern (format "ebox/border-%s-%s" side component))) + ebox-style--border-sides) + value)) + +(defun ebox-style--border-width-shorthand (value) + "Expand border width VALUE across all physical sides." + (ebox-style--border-component-shorthand 'width value)) + +(defun ebox-style--border-style-shorthand (value) + "Expand border style VALUE across all physical sides." + (ebox-style--border-component-shorthand 'style value)) + +(defun ebox-style--border-color-shorthand (value) + "Expand border color VALUE across all physical sides." + (ebox-style--border-component-shorthand 'color value)) + +(defun ebox-style--gap-shorthand (value) + "Expand gap VALUE to row and column gaps." + (ebox-style--pair-declarations '(ebox/row-gap ebox/column-gap) value)) + +(defun ebox-style--flex-flow-shorthand (value) + "Expand flex-flow VALUE to direction and wrapping longhands." + (let ((values (if (listp value) value (list value))) direction wrap) + (setq direction + (cl-find-if (lambda (item) + (memq item '(row row-reverse column column-reverse))) + values) + wrap (cl-find-if (lambda (item) + (memq item '(nowrap wrap wrap-reverse))) + values)) + (list 'ebox/flex-direction direction 'ebox/flex-wrap wrap))) + +(defun ebox-style--flex-shorthand (value) + "Expand flex item VALUE to grow, shrink, and basis longhands." + (pcase value + ((pred numberp) (list 'ebox/flex-grow value 'ebox/flex-shrink 1 + 'ebox/flex-basis 0)) + ('none '(ebox/flex-grow 0 ebox/flex-shrink 0 ebox/flex-basis auto)) + ('auto '(ebox/flex-grow 1 ebox/flex-shrink 1 ebox/flex-basis auto)) + ('initial '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto)) + ((pred listp) + (list 'ebox/flex-grow (or (nth 0 value) 0) + 'ebox/flex-shrink (or (nth 1 value) 1) + 'ebox/flex-basis (if (> (length value) 2) (nth 2 value) 'auto))) + (_ '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto)))) + +(defun ebox-style--normalizer (name) + "Return schema normalizer named NAME." + (pcase name + ('color #'ebox-style--parse-color) + ('vertical-align (lambda (value) (if (eq value 'middle) 'center value))) + (_ #'identity))) + +(defun ebox-style--negative-numeric-size-p (value) + "Return non-nil when VALUE is or starts with a negative number." + (or (and (numberp value) (< value 0)) + (and (consp value) + (numberp (car value)) + (< (car value) 0)))) + +(defun ebox-style--nonnegative-size-p (value) + "Return non-nil when VALUE is not a negative numeric size." + (not (ebox-style--negative-numeric-size-p value))) + +(defun ebox-style--nonnegative-number-p (value) + "Return non-nil when VALUE is nil or a non-negative number." + (or (null value) (and (numberp value) (>= value 0)))) + +(defun ebox-style--positive-integer-p (value) + "Return non-nil when VALUE is nil or a positive integer." + (or (null value) (and (integerp value) (> value 0)))) + +(defun ebox-style--grid-placement-p (value) + "Return non-nil when VALUE is a valid public Grid placement." + (or (null value) + (and (integerp value) (> value 0)) + (and (proper-list-p value) + (pcase value + (`(,start) + (and (integerp start) (> start 0))) + (`(,start :span ,span) + (and (integerp start) (> start 0) + (integerp span) (> span 0))) + (`(,start ,end) + (and (integerp start) (> start 0) + (integerp end) (> end start))))))) + +(defun ebox-style--display-p (value) + "Return non-nil when VALUE is nil or an Ebox display pair." + (or (null value) + (and (listp value) (= (length value) 2) + (cl-every #'symbolp value)))) + +(defun ebox-style--font-p (value) + "Return non-nil when VALUE is an Ebox font face specification." + (or (null value) (symbolp value) (stringp value) (proper-list-p value))) + +(defun ebox-style--font-family-p (value) + "Return non-nil when VALUE is an Ebox font family." + (or (null value) (stringp value) (symbolp value))) + +(defun ebox-style--font-height-p (value) + "Return non-nil when VALUE is an Emacs face height." + (or (null value) (numberp value) (functionp value))) + +(defun ebox-style--font-attribute-p (value) + "Return non-nil when VALUE is a symbolic font attribute." + (or (null value) (symbolp value))) + +(defun ebox-style--validator (name) + "Return schema validator named NAME." + (pcase name + ((or 'horizontal-size 'nonnegative-size) + #'ebox-style--nonnegative-size-p) + ('nonnegative-number #'ebox-style--nonnegative-number-p) + ('positive-integer #'ebox-style--positive-integer-p) + ('grid-placement #'ebox-style--grid-placement-p) + ('display #'ebox-style--display-p) + ('font #'ebox-style--font-p) + ('font-family #'ebox-style--font-family-p) + ('font-height #'ebox-style--font-height-p) + ('font-attribute #'ebox-style--font-attribute-p) + (_ (lambda (_value) t)))) + +(defun ebox-style--shorthand (name) + "Return shorthand expander named NAME." + (alist-get + name + '((padding . ebox-style--padding-shorthand) + (padding-inline . ebox-style--padding-inline-shorthand) + (padding-block . ebox-style--padding-block-shorthand) + (margin . ebox-style--margin-shorthand) + (margin-inline . ebox-style--margin-inline-shorthand) + (margin-block . ebox-style--margin-block-shorthand) + (border . ebox-style--border-shorthand) + (border-top . ebox-style--border-top-shorthand) + (border-right . ebox-style--border-right-shorthand) + (border-bottom . ebox-style--border-bottom-shorthand) + (border-left . ebox-style--border-left-shorthand) + (border-width . ebox-style--border-width-shorthand) + (border-style . ebox-style--border-style-shorthand) + (border-color . ebox-style--border-color-shorthand) + (gap . ebox-style--gap-shorthand) + (flex-flow . ebox-style--flex-flow-shorthand) + (flex . ebox-style--flex-shorthand)))) + +(defun ebox-style--schema-options (property) + "Return ECSS registration options for PROPERTY metadata." + (let ((options (list :initial (plist-get property :initial) + :inherits (plist-get property :inherits) + :normalizer (ebox-style--normalizer + (plist-get property :normalizer)) + :validator (ebox-style--validator + (plist-get property :validator)) + :equality #'equal))) + (when-let ((shorthand (ebox-style--shorthand + (plist-get property :shorthand)))) + (setq options (plist-put options :shorthand shorthand))) + options)) + +(defun ebox-style-register-properties () + "Register the complete namespaced Ebox property domain with ECSS." + (dolist (property ebox-style--property-definitions) + (apply #'ecss-schema-set-define + ebox-style-schemas (plist-get property :id) + (ebox-style--schema-options property)))) + +(defun ebox-style--ensure-properties () + "Ensure Ebox property schemas exist in the isolated ECSS schema set." + (unless (ecss-schema-set-property ebox-style-schemas 'ebox/color) + (ebox-style-register-properties))) + +(defvar ebox-style-stylesheet (ecss-stylesheet-create) + "Isolated ECSS stylesheet containing Ebox layout and paint rules.") + +(defun ebox-style-cascade-active-p () + "Return non-nil when the Ebox stylesheet contains cascade rules. +Inline declarations are compiled into Ebox engine properties while nodes are +created, so a surface with no stylesheet rules does not need a per-node ECSS +cascade pass during its initial static projection." + (not (null (ecss-stylesheet-rules ebox-style-stylesheet)))) + +(defun ebox-style-reset-rules () + "Clear all rules and cascade layers in `ebox-style-stylesheet'." + (ecss-stylesheet-clear ebox-style-stylesheet)) + +(defun ebox-style--selector (selector) + "Return ECSS selector AST compiled from Ebox SELECTOR." + (if (stringp selector) + (progn + (require 'ebox-selector) + (ebox-selector-parse selector)) + selector)) + +(defun ebox-style--custom-property-p (property) + "Return non-nil when PROPERTY names an ECSS custom property." + (and (symbolp property) + (string-prefix-p "--" (symbol-name property)))) + +(defun ebox-style--valid-plist-p (plist) + "Return non-nil when PLIST is a proper even-length list." + (and (proper-list-p plist) (zerop (% (length plist) 2)))) + +(defun ebox-style--static-invalid-declaration-p (validator value) + "Return non-nil when VALIDATOR rejects static VALUE." + (pcase validator + ('nonnegative-number + (and (numberp value) (< value 0))) + ((or 'horizontal-size 'nonnegative-size) + (ebox-style--negative-numeric-size-p value)) + ('positive-integer + (and (numberp value) (not (ebox-style--positive-integer-p value)))) + ('grid-placement + (and (or (numberp value) (proper-list-p value)) + (not (ebox-style--grid-placement-p value)))))) + +(defun ebox-style--validate-declaration-values (declarations) + "Return DECLARATIONS after static Ebox-owned value validation." + (cl-loop for (property value) on declarations by #'cddr + for schema = (ebox-style-property property) + for validator = (and schema (plist-get schema :validator)) + when (ebox-style--static-invalid-declaration-p validator value) + do (error "ebox: invalid value for %S: %S" + (plist-get schema :name) value)) + declarations) + +(defun ebox-style-compile-declarations (plist &optional strict) + "Compile Ebox PLIST aliases to ECSS schema declarations. +When STRICT is non-nil, reject properties outside the Ebox style domain." + (ebox-style--ensure-properties) + (unless (ebox-style--valid-plist-p plist) + (user-error "Ebox style declarations must be an even property list")) + (let ((canonical + (cl-loop for (property value) on plist by #'cddr + for schema = (ebox-style-schema-id property) + if schema append (list schema value) + else if (ebox-style--custom-property-p property) + append (list property value) + else if strict + do (user-error "Unknown Ebox style property: %S" + property)))) + (ebox-style--validate-declaration-values + (ecss-merge-declarations ebox-style-schemas canonical)))) + +(defun ebox-style-merge-declarations (base overrides) + "Merge canonical BASE and OVERRIDES through ECSS property schemas." + (ebox-style--ensure-properties) + (ecss-merge-declarations ebox-style-schemas base overrides)) + +(cl-defun ebox-style-add-rule + (selector declarations &key (origin 'author) layer scope) + "Add SELECTOR rule with Ebox DECLARATIONS to the isolated stylesheet. +ORIGIN, LAYER, and SCOPE use ECSS cascade semantics." + (ebox-style--ensure-properties) + (ecss-stylesheet-add-rule + ebox-style-stylesheet ebox-style-schemas (ebox-style--selector selector) + (ebox-style-compile-declarations declarations t) + :origin origin :layer layer + :scope (and scope (ebox-style--selector scope)))) + +(defun ebox-style-compute-subject (subject declarations &optional parent-style) + "Compute SUBJECT style from DECLARATIONS and optional PARENT-STYLE via ECSS." + (ebox-style--ensure-properties) + (ecss-compute-style + ebox-style-schemas subject :declarations declarations + :stylesheet ebox-style-stylesheet :parent-style parent-style + :value-resolver #'tp-resolve-value)) + +(defun ebox-style--public-computed-values (style) + "Return STYLE values using canonical public Ebox property names." + (let ((values (ecss-computed-style-values style)) result) + (dolist (property ebox-style--property-definitions) + (unless (plist-get property :shorthand) + (setq result + (plist-put result (plist-get property :name) + (plist-get values (plist-get property :id)))))) + result)) + +(defun ebox-style--common-border-value (style component) + "Return common border COMPONENT in STYLE, or nil when sides differ." + (let ((values + (mapcar (lambda (side) + (plist-get style + (intern (format ":border-%s-%s" side component)))) + ebox-style--border-sides))) + (when (cl-every (lambda (value) (equal value (car values))) (cdr values)) + (car values)))) + +(defun ebox-style--add-border-aggregates (style) + "Add uniform border shorthand facts to public computed STYLE." + (dolist (component '(width style color) style) + (when-let ((value (ebox-style--common-border-value style component))) + (setq style + (plist-put style (intern (format ":border-%s" component)) value))))) (defun ebox-style-compute (plist) - "Return computed canonical style for PLIST." - (let ((expanded (ebox-style-expand-shorthands plist)) - computed) - (while expanded - (let ((property (pop expanded)) - (value (pop expanded))) - (when (ebox-style-property property) - (setq computed - (ebox-style--put computed property value))))) - computed)) + "Return canonical public computed style for inline PLIST through ECSS." + (let ((style + (ecss-compute-style + ebox-style-schemas (ecss-subject-create :type "box") + :declarations (ebox-style-compile-declarations plist) + :rules nil :value-resolver #'tp-resolve-value))) + (ebox-style--add-border-aggregates + (ebox-style--public-computed-values style)))) + +(defun ebox-style-expand-shorthands (plist) + "Expand PLIST to canonical public longhands through ECSS schemas." + (ebox-style-compute plist)) + +(defun ebox-style-computed-active-p (style property) + "Return non-nil when computed STYLE actively contributes PROPERTY." + (memq (ebox-style-schema-id property) + (ecss-computed-style-active-properties style))) + +(defun ebox-style-computed-value (style property) + "Return computed STYLE value for public Ebox PROPERTY." + (plist-get (ecss-computed-style-values style) + (ebox-style-schema-id property))) + +(defun ebox-style--computed-snapshot (style &optional specified-p) + "Return one detached ECSS values snapshot for STYLE projection. +When SPECIFIED-P is non-nil, include declaration-winner facts." + (list (ecss-computed-style-values style) + (ecss-computed-style-active-properties style) + (and specified-p + (ecss-computed-style-specified-properties style)))) + +(defun ebox-style--snapshot-active-p (snapshot property) + "Return non-nil when SNAPSHOT actively contributes PROPERTY." + (memq (ebox-style-schema-id property) (nth 1 snapshot))) + +(defun ebox-style--context-values + (style context &optional specified-only snapshot) + "Return public STYLE values for CONTEXT. +When SPECIFIED-ONLY is non-nil, exclude initial and inherited-only facts. +SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." + (let* ((snapshot + (or snapshot + (ebox-style--computed-snapshot style specified-only))) + (values (nth 0 snapshot)) + (active (nth 1 snapshot)) + (specified + (and specified-only + (nth 2 snapshot))) + result) + (dolist (property ebox-style--property-definitions) + (let ((id (plist-get property :id))) + (when (and (not (plist-get property :shorthand)) + (memq context (plist-get property :contexts)) + (memq id active) + (or (not specified-only) + (memq id specified))) + (setq result + (plist-put result (plist-get property :name) + (plist-get values id)))))) + result)) + +(defun ebox-style--specified-property-p (style property) + "Return non-nil when STYLE PROPERTY has a declaration winner." + (ecss-computed-style-specified-p style property)) + +(defun ebox-style-node-declarations (node) + "Return canonical ECSS declarations stored on semantic Ebox NODE." + (let ((declarations (plist-get node :ebox-style-declarations))) + (if (memq (plist-get node :ebox-type) '(flex grid)) + (ebox-style-merge-declarations + declarations + (plist-get (plist-get node :box) :ebox-style-overrides)) + (copy-sequence declarations)))) + +(defun ebox-style--remove-style-properties (plist) + "Return PLIST without Ebox style or ECSS custom properties." + (cl-loop for (property value) on plist by #'cddr + unless (or (ebox-style-property property) + (ebox-style--custom-property-p property)) + append (list property value))) + +(defun ebox-style--reset-box-engine-style (box) + "Reset BOX engine style fields to their declared defaults." + (cl-loop for (property value) on ebox--longhand by #'cddr + unless (memq property '(:content :scroll-offset)) + do (plist-put box property (copy-tree value))) + box) + +(defun ebox-style--apply-engine-values (box values) + "Project public style VALUES into BOX engine fields." + (ebox-style--reset-box-engine-style box) + (let ((expanded (ebox-style-expand-ebox-plist values))) + (cl-loop for (property value) on expanded by #'cddr + do (plist-put box property value))) + box) + +(defun ebox-style--box-values (style &optional snapshot) + "Return computed box values ready for Ebox engine projection." + (let* ((snapshot (or snapshot (ebox-style--computed-snapshot style))) + (values (ebox-style--context-values style 'box nil snapshot))) + (when (and (ebox-style--snapshot-active-p snapshot :color) + (null (plist-get values :color))) + (setq values + (plist-put values :color ebox-style--default-foreground))) + values)) + +(defconst ebox-style--container-wrapper-neutral-properties + '(:width :height :box-sizing :color + :font :font-family :font-height :font-weight :font-slant) + "Container properties that do not require an internal visual wrapper.") + +(defun ebox-style--container-wrapper-needed-p (style &optional snapshot) + "Return non-nil when STYLE needs a visual container wrapper." + (cl-loop for (property value) + on (ebox-style--context-values style 'box t snapshot) by #'cddr + thereis (and value + (not (memq + property + ebox-style--container-wrapper-neutral-properties))))) + +(defun ebox-style--new-container-wrapper () + "Return a stable internal box for container-owned box paint." + (let ((wrapper (ebox-create :content "" :wrap-mode nil + :key 'ebox/style-wrapper))) + (plist-put wrapper :ebox-style-wrapper t) + (plist-put wrapper :ebox-style-generated-wrapper t) + wrapper)) + +(defun ebox-style--container-wrapper (node style &optional snapshot) + "Return NODE wrapper required by STYLE, updating NODE ownership." + (let ((wrapper (plist-get node :box)) + (needed (ebox-style--container-wrapper-needed-p style snapshot))) + (cond + (needed + (unless wrapper + (setq wrapper (ebox-style--new-container-wrapper)) + (plist-put node :box wrapper)) + wrapper) + ((and wrapper (plist-get wrapper :ebox-style-generated-wrapper)) + (plist-put node :box nil) + nil) + (t wrapper)))) + +(defun ebox-style--apply-container-wrapper (node style &optional snapshot) + "Apply computed STYLE to NODE's internal visual wrapper." + (when-let ((wrapper (ebox-style--container-wrapper node style snapshot))) + (ebox-style--apply-engine-values + wrapper + (ebox-style--box-values style snapshot)) + (unless (ebox-style--specified-property-p style 'ebox/wrap-mode) + (plist-put wrapper :wrap-mode nil)))) + +(defun ebox-style--grid-values (values) + "Translate canonical container VALUES to Grid engine property names." + (let ((row-present (plist-member values :row-gap)) + (column-present (plist-member values :column-gap))) + (when row-present + (setq values + (plist-put values :grid-row-gap (plist-get values :row-gap)))) + (when column-present + (setq values + (plist-put values :grid-column-gap + (plist-get values :column-gap)))) + (cl-loop for (property value) on values by #'cddr + unless (memq property '(:row-gap :column-gap)) + append (list property value)))) + +(defun ebox-style--apply-container (node style &optional snapshot) + "Apply computed STYLE to Flex or Grid NODE." + (let* ((type (plist-get node :ebox-type)) + (metadata + (ebox-style--remove-style-properties (plist-get node :raw-props))) + (values + (append (ebox-style--context-values style 'box nil snapshot) + (ebox-style--context-values style type nil snapshot))) + (raw (append metadata + (if (eq type 'grid) + (ebox-style--grid-values values) + values)))) + (plist-put node :raw-props raw) + (plist-put node :props + (if (eq type 'flex) + (ebox--flex-normalize-container-props raw) + raw)) + (ebox-style--apply-container-wrapper node style snapshot))) + +(defun ebox-style--delete-node-property (node property) + "Delete PROPERTY from NODE in place while preserving NODE identity." + (when (eq (car node) property) + (error "Ebox internal node property cannot occupy plist head: %S" property)) + (let ((tail node)) + (while (cddr tail) + (if (eq (caddr tail) property) + (setcdr (cdr tail) (cddddr tail)) + (setq tail (cddr tail))))) + node) + +(defun ebox-style--apply-item (node style &optional snapshot) + "Apply computed Flex/Grid item fields from STYLE to NODE." + (let ((values (ebox-style--context-values style 'item nil snapshot))) + (dolist (property '(:order :flex-grow :flex-shrink :flex-basis :align-self + :grid-column :grid-row + :grid-column-span :grid-row-span)) + (ebox-style--delete-node-property node property)) + (cl-loop for (property value) on values by #'cddr + do (plist-put node property value))) + node) + +(defun ebox-style-apply-computed (node style) + "Apply ECSS computed STYLE consequences to semantic Ebox NODE." + (unless (ecss-computed-style-p style) + (signal 'wrong-type-argument (list 'ecss-computed-style-p style))) + (let* ((type (plist-get node :ebox-type)) + (snapshot + (ebox-style--computed-snapshot style (memq type '(flex grid))))) + (plist-put node :ebox-computed-style style) + (when (ebox-style--snapshot-active-p snapshot :display) + (plist-put node :display + (ebox-style-computed-value style :display))) + (pcase type + ('box + (ebox-style--apply-engine-values + node + (ebox-style--box-values style snapshot))) + ((or 'flex 'grid) + (ebox-style--apply-container node style snapshot))) + (ebox-style--apply-item node style snapshot)) + node) + +(defun ebox-style-sync-flex-item (item) + "Synchronize internal Flex ITEM props from its semantic source style." + (when (eq (plist-get item :ebox-type) 'flex-item) + (when-let* ((source (plist-get item :node))) + (let* ((source-style (plist-get source :ebox-computed-style)) + (child-style + (when-let ((child (plist-get source :ebox-content-node))) + (plist-get child :ebox-computed-style))) + (values + (append + (and source-style + (ebox-style--context-values source-style 'item)) + (and child-style + (ebox-style--context-values child-style 'item))))) + (when (or source-style child-style) + (plist-put item :props values))))) + item) (defun ebox-style-dirty-kind (name) "Return dirty kind for canonical property or alias NAME." (when-let ((property (ebox-style-property name))) - (ebox-style-property-dirty-kind property))) + (plist-get property :dirty-kind))) (defun ebox-style-signature (computed-style groups) "Return deterministic signature for COMPUTED-STYLE filtered by GROUPS." @@ -293,11 +980,11 @@ (let* ((property-name (pop computed-style)) (value (pop computed-style)) (property (ebox-style-property property-name)) - (group (and property (ebox-style-property-signature property)))) + (group (and property (plist-get property :signature)))) (when (and property (or (null groups) (memq group groups) - (memq (ebox-style-property-group property) groups))) + (memq (plist-get property :group) groups))) (push (cons property-name value) entries)))) (setq entries (sort entries @@ -309,6 +996,8 @@ (list (car entry) (cdr entry))) entries)))) +(ebox-style-register-properties) + (defconst ebox-style-ebox-property-rules '((:padding :expand (:padding-top :padding-right :padding-bottom :padding-left) @@ -316,18 +1005,26 @@ (:padding-inline :expand (:padding-left :padding-right) :mode pair) (:padding-block :expand (:padding-top :padding-bottom) :mode pair) (:padding-top :to :padding-top-height :conv line) + (:padding-block-start :to :padding-top-height :conv line) (:padding-right :to :padding-right-pixel :conv pixel) + (:padding-inline-end :to :padding-right-pixel :conv pixel) (:padding-bottom :to :padding-bottom-height :conv line) + (:padding-block-end :to :padding-bottom-height :conv line) (:padding-left :to :padding-left-pixel :conv pixel) + (:padding-inline-start :to :padding-left-pixel :conv pixel) (:margin :expand (:margin-top :margin-right :margin-bottom :margin-left) :mode trbl) (:margin-inline :expand (:margin-left :margin-right) :mode pair) (:margin-block :expand (:margin-top :margin-bottom) :mode pair) (:margin-top :to :margin-top-height :conv line) + (:margin-block-start :to :margin-top-height :conv line) (:margin-right :to :margin-right-pixel :conv pixel) + (:margin-inline-end :to :margin-right-pixel :conv pixel) (:margin-bottom :to :margin-bottom-height :conv line) + (:margin-block-end :to :margin-bottom-height :conv line) (:margin-left :to :margin-left-pixel :conv pixel) + (:margin-inline-start :to :margin-left-pixel :conv pixel) (:border :expand (:border-top :border-right :border-bottom :border-left) :mode same) @@ -390,13 +1087,6 @@ (:visibility :to :visibility)) "Compatibility rules that expand public Ebox properties to engine keys.") -(defun ebox-style--negative-numeric-size-p (value) - "Return non-nil when VALUE is or starts with a negative number." - (or (and (numberp value) (< value 0)) - (and (consp value) - (numberp (car value)) - (< (car value) 0)))) - (defun ebox-style--convert (value converter) "Convert VALUE using Ebox engine CONVERTER." (pcase converter diff --git a/ebox-surface.el b/ebox-surface.el index a2dd0a3..3be0d17 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -11,6 +11,9 @@ ;;; Code: (require 'cl-lib) +(require 'seq) +(require 'ebox-buffer-backend) +(require 'ebox-style) (require 'ebox-tree) (require 'ebox-layout) (require 'ebox-incremental) @@ -29,12 +32,19 @@ (defvar ebox--render-runtime-revision) (defvar ebox--render-cache-table) (defvar ebox--render-cache-signature-cache) +(defvar ebox--layout-fragments-table) +(defvar ebox--layout-fragments-reuse-p) (defvar ebox--flex-content-min-width-table) (defvar ebox--scroll-window-initial-lookahead-lines-override) (defvar ebox-viewport-width) (defvar ebox-viewport-height) +(defvar ebox--render-display-signature) (declare-function ebox--render-layout "ebox-layout" (node)) +(declare-function ebox--record-render-output-provenance + "ebox-render-context" (rendered)) +(declare-function ebox--render-owned-text-value-p + "ebox-render-context" (property value &optional registry)) (declare-function ebox-incremental--hash-snapshot "ebox-incremental" (table keys)) (declare-function ebox-incremental--restore-hash-snapshot @@ -53,6 +63,18 @@ "ebox" (region-id &optional delay)) (declare-function ebox--scroll-clear-state "ebox" (region-id)) (declare-function ebox--smooth-scroll-stop "ebox" (region-id)) +(declare-function ebox-put "ebox" (box property value)) +(declare-function ebox-string-lines "ebox" (string)) +(declare-function tp-object-mounted-p "tp-surface" (object)) +(declare-function tp-object-attach-content-ranges-owned + "tp-surface" (context leaf ranges)) +(declare-function tp-text-snapshot + "tp-core" (text &optional reuse-property-p)) +(declare-function tp-surface-plan-create-owned + "tp-surface" (&rest arguments)) +(declare-function tp-surface-result-create-owned + "tp-surface" (context plan &optional client-state)) +(declare-function tp-surface-report-summary "tp-surface" (surface)) (defvar-local ebox-surface--buffer-surface nil "Live TP content surface mounted for the current Ebox buffer.") @@ -65,6 +87,50 @@ (defvar-local ebox-surface--context-signals nil "Buffer-scoped TP signals consumed by the mounted Ebox producer.") +(defconst ebox-surface--root-key 'ebox/surface + "Stable TP key for an Ebox surface projection root.") + +(defconst ebox-surface--nodes-key 'ebox/nodes + "Stable TP key for the retained logical Ebox node tree.") + +(defconst ebox-surface--fragments-key 'ebox/fragments + "Stable TP key for linear rendered fragments.") + +(defconst ebox-surface--text-key 'ebox/text + "Stable TP key for the shared rendered text leaf.") + +(defconst ebox-surface--inherited-style-properties + '(ebox/color ebox/font ebox/font-family ebox/font-height + ebox/font-weight ebox/font-slant) + "ECSS properties whose inline values can flow to descendants.") + +(defun ebox-surface--inline-inheritance-required-p (root) + "Return non-nil when inline styles in ROOT require an ECSS cascade. +An empty stylesheet still needs a cascade when an inherited declaration can +reach a descendant that does not declare the same property. Other inline +declarations are already projected into the candidate engine fields at node +construction time and can use the static projection path." + (let ((pending (list (cons root nil))) + required) + (while (and pending (not required)) + (let* ((entry (pop pending)) + (node (car entry)) + (inherited (cdr entry)) + (declarations (ebox-style-node-declarations node))) + (when (cl-some (lambda (property) + (and (memq property inherited) + (not (plist-member declarations property)))) + ebox-surface--inherited-style-properties) + (setq required t)) + (unless required + (let ((next (copy-sequence inherited))) + (dolist (property ebox-surface--inherited-style-properties) + (when (plist-member declarations property) + (cl-pushnew property next))) + (dolist (child (ebox-tree-node-children node)) + (push (cons child next) pending)))))) + required)) + (defun ebox-surface--signals-live-p (signals) "Return non-nil when every signal in SIGNALS is live." (and (ebox-surface--signals-p signals) @@ -216,7 +282,7 @@ ROLES has the same filtering meaning as in `ebox-surface-region-mounts'." (defun ebox-surface--commit-report (surface state report-base) "Return Ebox's compact report for SURFACE, STATE, and REPORT-BASE." - (let* ((tp-report (tp-surface-report surface)) + (let* ((tp-report (tp-surface-report-summary surface)) (text-operations (or (plist-get tp-report :text-operations) 0)) (property-operations (or (plist-get tp-report :property-operations) 0)) @@ -376,7 +442,8 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime." (setq surface (tp-surface-mount buffer producer - '(:capability content :inhibit-read-only t)))) + '(:capability content :inhibit-read-only t + :coordinate-mounts t)))) (ebox-surface--publish-runtime-state buffer surface old-state report-base after-publication)) (setq success t) @@ -493,27 +560,73 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime." (plist-get commit-input :scope-node-ids) (plist-get commit-input :report-base) (plist-get commit-input :state-overrides) - nil nil 0) + nil nil 0 + (plist-get commit-input :projection-kind) + ;; The incremental viewport candidate aliases the published root + ;; while it proves the reflow shape. Let the surface create its + ;; own copy before layout can attach caches or TP handles. + nil) (plist-get (tp-surface-client-state surface) :last-update-report))))))) -(defun ebox-surface--projection-start (context root) - "Create retained TP identities for ROOT in CONTEXT." +(defun ebox-surface--projection-start + (context root &optional style-required-p projection-kind previous-state) + "Create retained TP identities and optional computed styles for ROOT. +STYLE-REQUIRED-P is non-nil when the candidate must refresh cascade results. +When it is nil, inline declarations already projected by node construction +are sufficient for the static layout pass. PROJECTION-KIND may request a +strict span patch or retained viewport reflow, both of which reuse +PREVIOUS-STATE's node-object table." (let* ((surface-root (tp-object-ensure context nil ebox-surface--root-key 'ebox/surface)) (node-root (tp-object-ensure context surface-root ebox-surface--nodes-key 'ebox/nodes)) - (objects-by-node (make-hash-table :test 'eq))) + (objects-by-node + (if (memq projection-kind + '(span-patch viewport-reflow viewport-reflow-mixed-scroll)) + (or (plist-get previous-state :surface-node-object-table) + (error "Ebox span projection has no retained node table")) + (make-hash-table :test 'eq))) + (bindings-by-subject (make-hash-table :test 'eq)) + (subjects (when style-required-p + (ebox-tree-subject-index root)))) + (if (memq projection-kind + '(span-patch viewport-reflow viewport-reflow-mixed-scroll)) + (tp-object-reuse-subtree context node-root) + (progn + (tp-object-retain context node-root) + (ebox-surface--ensure-node-tree + context node-root root objects-by-node + (and subjects (plist-get subjects :node-subject-table)) + bindings-by-subject))) + (list :surface-root surface-root + :node-root node-root + :objects-by-node objects-by-node))) + +(defun ebox-surface--complete-projection + (context root style-required-p projection) + "Complete a retained PROJECTION for ROOT after a local proof miss. +The initial span attempt already owns the surface and node-root identities in +CONTEXT, so full fallback must populate those same objects instead of creating +the projection roots a second time." + (let* ((node-root (plist-get projection :node-root)) + (objects-by-node (plist-get projection :objects-by-node)) + (bindings-by-subject (make-hash-table :test 'eq)) + (subjects (when style-required-p + (ebox-tree-subject-index root)))) (tp-object-retain context node-root) - (ebox-surface--ensure-node-tree context node-root root objects-by-node) - (cons surface-root objects-by-node))) + (ebox-surface--ensure-node-tree + context node-root root objects-by-node + (and subjects (plist-get subjects :node-subject-table)) + bindings-by-subject) + projection)) (defun ebox-surface--projection-result - (context projection state rendered) - "Complete CONTEXT PROJECTION for STATE from RENDERED output." - (let* ((surface-root (car projection)) - (objects-by-node (cdr projection)) + (context projection state output) + "Complete CONTEXT PROJECTION for STATE from rendered OUTPUT." + (let* ((surface-root (plist-get projection :surface-root)) + (objects-by-node (plist-get projection :objects-by-node)) (node-objects (ebox-surface--node-object-table objects-by-node)) (region-objects (ebox-surface--region-object-table state node-objects))) @@ -523,10 +636,11 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime." (ebox-surface--object-region-table region-objects)) (plist-put state :logical-id-region-table (ebox-surface--logical-id-region-table state region-objects)) - (tp-surface-result-create - (ebox-surface--surface-plan - context surface-root rendered state node-objects region-objects) - state))) + (let* ((prepared + (ebox-surface--surface-plan + context surface-root output state node-objects region-objects)) + (plan (car prepared))) + (tp-surface-result-create-owned context plan state)))) (defun ebox-surface--mounted-object-for-node (state node-id) "Return NODE-ID's nearest live retained object with mounts in STATE." @@ -536,10 +650,10 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime." (while (and node-id (progn (setq object (and objects (gethash node-id objects))) - (or (not object) (null (tp-object-mounts object))))) + (or (not object) (not (tp-object-mounted-p object))))) (setq node-id (and parents (gethash node-id parents)))) (unless (and object (tp-object-live-p object) - (tp-object-mounts object)) + (tp-object-mounted-p object)) (error "Ebox scoped owner has no mounted TP object")) object)) @@ -552,12 +666,18 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime." (defun ebox-surface-update-buffer-scoped (buffer source scope-node-ids report-base state-overrides - &optional after-publication on-mismatch scroll-prefetch-delay) + &optional after-publication on-mismatch scroll-prefetch-delay + projection-kind source-isolated-p) "Publish BUFFER SOURCE within SCOPE-NODE-IDS through TP. STATE-OVERRIDES augments the isolated candidate runtime. REPORT-BASE and AFTER-PUBLICATION have the same meaning as in `ebox-surface-mount-buffer'. ON-MISMATCH is forwarded to TP's scoped publication policy. -SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming." +SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming. +PROJECTION-KIND may request a proven non-spatial candidate projection. +SOURCE-ISOLATED-P means SOURCE is an internally copied runtime candidate and +may be adopted without another structural copy or identity reconciliation. +When SOURCE-PATH-COPIED-P is non-nil, SOURCE shares untouched published nodes; +the surface must preserve those shared nodes while consuming the candidate." (let* ((surface (ebox-surface--live-buffer-surface buffer)) (old-state (and surface (tp-surface-client-state surface)))) (unless surface @@ -573,8 +693,10 @@ SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming." (signals (car signals-result)) (signals-created-p (cdr signals-result)) (producer - (ebox-surface-producer - source old-state t state-overrides signals)) + (ebox-surface-producer + source old-state t state-overrides signals projection-kind + source-isolated-p + (plist-get state-overrides :source-path-copied-p))) success) (unwind-protect (progn @@ -582,7 +704,7 @@ SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming." (ebox-surface--stage-signal-values signals context-values) (tp-surface-update-scoped surface objects producer - (and on-mismatch (list :on-mismatch on-mismatch))) + (list :on-mismatch on-mismatch :return-report nil)) (ebox-surface--publish-runtime-state buffer surface old-state report-base after-publication)) (setq success t) @@ -600,15 +722,6 @@ SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming." (when (and signals-created-p (not success)) (ebox-surface--dispose-signals signals))))))) -(defconst ebox-surface--root-key 'ebox/surface - "Stable TP key for an Ebox surface projection root.") - -(defconst ebox-surface--nodes-key 'ebox/nodes - "Stable TP key for the retained logical Ebox node tree.") - -(defconst ebox-surface--fragments-key 'ebox/fragments - "Stable TP key for linear rendered fragments.") - (defun ebox-surface--clear-runtime-attachments (root) "Clear TP handles and render-cache attachments below ROOT." (cl-labels @@ -623,9 +736,8 @@ SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming." (visit root)) root) -(defun ebox-surface--candidate-root - (source previous-state preserve-identities-p) - "Return an isolated runtime copy of SOURCE for PREVIOUS-STATE. +(defun ebox-surface--candidate-root (source preserve-identities-p) + "Return an isolated runtime copy of SOURCE. When PRESERVE-IDENTITIES-P is non-nil, retain existing Ebox node and region identities while always discarding TP handles and render-cache attachments." (unless (and (listp source) (not (stringp source))) @@ -635,10 +747,29 @@ identities while always discarding TP handles and render-cache attachments." (if preserve-identities-p (ebox-surface--clear-runtime-attachments candidate) (ebox-tree-clear-runtime-identities candidate)) - (when-let ((previous-root (plist-get previous-state :root-node))) - (ebox-tree-reconcile-runtime previous-root candidate)) candidate)) +(defun ebox-surface--isolated-viewport-overrides (overrides) + "Copy mutable viewport caches in OVERRIDES for a private reflow candidate. +The retained viewport projection may reuse the TP object topology, but its +layout pass still writes Ebox-side caches. Keep those writes out of the +published generation so a failed transaction has no cache state to restore." + (let ((copy (copy-sequence overrides))) + (dolist (key '(:render-cache :layout-fragments :render-signature-cache + :flex-content-min-widths + :viewport-height-dependent-subtree-cache)) + (when (plist-member copy key) + (let ((value (plist-get copy key))) + (when (hash-table-p value) + (setq copy (plist-put copy key (copy-hash-table value))))))) + copy)) + +(defun ebox-surface--reconcile-candidate (root previous-state) + "Reconcile styled ROOT with PREVIOUS-STATE runtime identities." + (when-let ((previous-root (plist-get previous-state :root-node))) + (ebox-tree-reconcile-runtime previous-root root)) + root) + (defun ebox-surface--source-region-id-set (source) "Return region ids already present in Ebox SOURCE without mutating it." (let ((ids (make-hash-table :test 'equal))) @@ -667,8 +798,35 @@ identities while always discarding TP handles and render-cache attachments." "Return the TP kind discriminator for Ebox NODE." (list 'ebox/node (plist-get node :ebox-type))) -(defun ebox-surface--ensure-node-tree (context parent node table) - "Ensure NODE and descendants below PARENT in CONTEXT and fill TABLE." +(defun ebox-surface--node-style-binding + (object node subject bindings-by-subject) + "Install and return NODE style binding on OBJECT for SUBJECT." + (let* ((parent (ecss-subject-parent subject)) + (parent-binding (and parent (gethash parent bindings-by-subject))) + (declarations (ebox-style-node-declarations node)) + (binding + (tp-bind + object 'ebox/computed-style + (lambda () + (ebox-style-compute-subject + subject declarations + (and parent-binding (tp-binding-read parent-binding))))))) + (puthash subject binding bindings-by-subject) + binding)) + +(defun ebox-surface--apply-node-style + (object node subject-table bindings-by-subject) + "Compute and apply NODE style using retained OBJECT and SUBJECT-TABLE." + (when-let ((subject (gethash node subject-table))) + (ebox-style-apply-computed + node + (tp-binding-read + (ebox-surface--node-style-binding + object node subject bindings-by-subject))))) + +(defun ebox-surface--ensure-node-tree + (context parent node table subject-table bindings-by-subject) + "Ensure styled NODE descendants below PARENT in CONTEXT and fill TABLE." (let ((object (tp-object-ensure context parent (ebox-surface--node-key node) @@ -676,8 +834,14 @@ identities while always discarding TP handles and render-cache attachments." (tp-object-retain context object) (plist-put node :surface-object object) (puthash node object table) + (when subject-table + (ebox-surface--apply-node-style + object node subject-table bindings-by-subject)) (dolist (child (ebox-tree--children-raw node)) - (ebox-surface--ensure-node-tree context object child table)) + (ebox-surface--ensure-node-tree + context object child table subject-table bindings-by-subject)) + (when subject-table + (ebox-style-sync-flex-item node)) object)) (defun ebox-surface--node-object-table (objects-by-node) @@ -685,7 +849,9 @@ identities while always discarding TP handles and render-cache attachments." (let ((table (make-hash-table :test 'equal))) (maphash (lambda (node object) - (let ((node-id (plist-get node :node-id))) + (let ((node-id (if (listp node) + (plist-get node :node-id) + node))) (unless node-id (error "Ebox rendered node has no runtime identity")) (puthash node-id object table))) @@ -805,7 +971,11 @@ valid because their declarative layout and viewport did not change." snapshot)) (defun ebox-surface--render-candidate (state) - "Render candidate STATE in isolated Ebox side tables." + "Render candidate STATE or optional NODE in isolated side tables." + (ebox-surface--render-candidate-node state (plist-get state :root-node))) + +(defun ebox-surface--render-candidate-node (state node) + "Render NODE from candidate STATE in isolated Ebox side tables." (let ((scroll-table (or (plist-get state :scroll-state-table) (make-hash-table :test 'equal))) @@ -817,15 +987,21 @@ valid because their declarative layout and viewport did not change." (timer-table (make-hash-table :test 'equal)) (smooth-table (make-hash-table :test 'equal))) (cl-remf state :preserve-scroll-producer-region-ids) - (let ((ebox-viewport-width (plist-get state :viewport-width)) - (ebox-viewport-height (plist-get state :viewport-height)) - (ebox--region-box-table (plist-get state :region-box-table)) + (let ((ebox-viewport-width (plist-get state :viewport-width)) + (ebox-viewport-height (plist-get state :viewport-height)) + (ebox--render-display-signature + (plist-get state :display-signature)) + (ebox--region-box-table (plist-get state :region-box-table)) (ebox--scroll-global-state scroll-table) (ebox--scroll-idle-prefetch-timers timer-table) (ebox--smooth-scroll-state-table smooth-table) (ebox--render-runtime-revision (plist-get state :runtime-revision)) (ebox--render-cache-table (plist-get state :render-cache)) + (ebox--layout-fragments-table + (plist-get state :layout-fragments)) + (ebox--layout-fragments-reuse-p + (plist-get state :layout-fragments-reuse-p)) (ebox--render-cache-signature-cache (plist-get state :render-signature-cache)) (ebox--scroll-window-initial-lookahead-lines-override @@ -837,34 +1013,257 @@ valid because their declarative layout and viewport did not change." (ebox--viewport-height-dependent-subtree-cache (plist-get state :viewport-height-dependent-subtree-cache)) (ebox--flex-content-min-width-table - (plist-get state :flex-content-min-widths))) + (plist-get state :flex-content-min-widths)) + (ebox--render-owned-text-values + (make-hash-table :test #'eq))) (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (&rest _) nil))) - (prog1 (let ((ebox--surface-materialization-active t)) - (ebox--render-layout (plist-get state :root-node))) + (let ((rendered + (let ((ebox--surface-materialization-active t)) + (ebox--render-layout node)))) + (ebox--record-render-output-provenance rendered) (ebox-surface--restore-scroll-metadata scroll-table scroll-metadata) (plist-put state :scroll-state-table scroll-table) (plist-put state :scroll-region-ids - (ebox-surface--hash-keys scroll-table))))))) + (ebox-surface--hash-keys scroll-table)) + (plist-put state :render-owned-text-values + ebox--render-owned-text-values) + rendered))))) + +(defun ebox-surface--attach-retained-node-objects (state) + "Attach retained TP objects to candidate nodes in STATE." + (let ((nodes (plist-get state :node-table)) + (objects (plist-get state :surface-node-object-table))) + (when (and (hash-table-p nodes) (hash-table-p objects)) + (maphash + (lambda (node-id object) + (when-let ((node (gethash node-id nodes))) + (plist-put node :surface-object object))) + objects)) + state)) + +(defun ebox-surface--rendered-role-topology-signature + (rendered region-ids) + "Return rendered Ebox role topology for REGION-IDS in RENDERED." + (let ((wanted (make-hash-table :test 'equal)) + (roles-table (make-hash-table :test 'eq)) + (position 0) + (limit (length rendered))) + (dolist (region-id region-ids) + (puthash region-id t wanted)) + (while (< position limit) + (dolist (entry (ebox-surface--role-ids-at rendered position)) + (when (gethash (cdr entry) wanted) + (puthash (car entry) t roles-table))) + (setq position + (max (or (next-property-change position rendered limit) limit) + (1+ position)))) + (let (roles) + (maphash (lambda (role _value) (push role roles)) roles-table) + (list :roles + (sort roles + (lambda (left right) + (string< (symbol-name left) + (symbol-name right)))))))) + +(defun ebox-surface--span-patch-lines (buffer spans rendered) + "Return RENDERED shaped for BUFFER SPANS, or nil when unsafe." + (with-current-buffer buffer + (if (ebox-buffer--partial-line-slots-p spans) + (ebox-buffer--rendered-in-existing-slots spans rendered nil t) + (and (= (length spans) (length (ebox-string-lines rendered))) + rendered)))) + +(defun ebox-surface--projected-span-positions (spans lines) + "Return BUFFER span positions after replacing SPANS with LINES." + (when (= (length spans) (length lines)) + (let ((delta 0) result) + (cl-loop for span in spans + for line in lines + do (let* ((start (+ (car span) delta)) + (end (+ start (length line)))) + (push (cons start end) result) + (setq delta + (+ delta (- (length line) + (- (cdr span) (car span))))))) + (nreverse result)))) + +(defun ebox-surface--replace-buffer-spans + (source origin spans lines) + "Return SOURCE with buffer SPANS replaced by propertized LINES." + (catch 'invalid + (let ((cursor origin) pieces) + (cl-loop for span in spans + for line in lines + do (let ((start (car span)) (end (cdr span))) + (when (or (< start cursor) (< end start)) + (throw 'invalid nil)) + (push (substring source (- cursor origin) (- start origin)) + pieces) + (push line pieces) + (setq cursor end))) + (push (substring source (- cursor origin)) pieces) + (apply #'concat (nreverse pieces))))) + +(defun ebox-surface--span-patch-details + (_buffer snapshot node spans rendered) + "Return validated replacement details for SNAPSHOT and RENDERED." + (let* ((lines (ebox-string-lines rendered)) + (new-spans (ebox-surface--projected-span-positions spans lines)) + (old-footprint (plist-get snapshot :span-footprint-signature)) + (new-footprint + (and new-spans (ebox--rendered-span-footprint-signature rendered))) + (new-external + (and new-footprint + (ebox--external-footprint-signature-from-span-footprint + new-footprint))) + (new-parent + (and new-footprint + (ebox--project-parent-slot-signature + (plist-get snapshot :parent-slot-signature) + new-footprint))) + (new-roles + (and new-spans + (ebox-surface--rendered-role-topology-signature + rendered (plist-get snapshot :region-ids)))) + (new-overflow (ebox--overflow-signature node))) + (when (and old-footprint new-footprint + (ebox--span-footprint-compatible-p + old-footprint new-footprint) + (ebox--external-footprint-compatible-p + (plist-get snapshot :external-footprint-signature) + new-external) + (ebox--parent-slot-compatible-p + (plist-get snapshot :parent-slot-signature) new-parent) + (equal (plist-get snapshot :role-topology-signature) + new-roles) + (equal (plist-get snapshot :overflow-signature) + new-overflow)) + (list :lines lines :spans new-spans :footprint new-footprint + :external new-external :parent new-parent :roles new-roles + :overflow new-overflow)))) + +(defun ebox-surface--install-span-patch-snapshot + (state owner-id snapshot details) + "Install DETAILS for OWNER-ID into candidate STATE snapshots." + (let* ((generation (1+ (or (plist-get state + :layout-snapshot-detail-generation) + 0))) + (updated (copy-sequence snapshot)) + (spans (plist-get details :spans)) + (footprint (plist-get details :footprint))) + (dolist (entry `((:buffer-span . ,(car spans)) + (:buffer-spans . ,spans) + (:line-signature . ,(plist-get footprint + :line-pixel-widths)) + (:span-footprint-signature . ,footprint) + (:external-footprint-signature + . ,(plist-get details :external)) + (:parent-slot-signature . ,(plist-get details :parent)) + (:role-topology-signature . ,(plist-get details :roles)) + (:overflow-signature . ,(plist-get details :overflow)) + (:detail-generation . ,generation))) + (setq updated (plist-put updated (car entry) (cdr entry)))) + (puthash owner-id updated (plist-get state :layout-snapshots)) + (plist-put state :layout-snapshot-detail-generation generation) + (plist-put state :layout-snapshots-complete-p nil) + state)) + +(defun ebox-surface--span-patch-output + (buffer state) + "Return a proven full output for OWNER-ID's local span patch, or nil." + (let* ((owner-id (plist-get state :span-patch-owner-id)) + (old-snapshot + (and owner-id (ebox--ensure-layout-snapshot-details buffer owner-id))) + (spans (and old-snapshot (plist-get old-snapshot :buffer-spans))) + (node (and owner-id (gethash owner-id (plist-get state :node-table))))) + (when (and old-snapshot spans node) + (with-current-buffer buffer + (save-restriction + (widen) + (let* ((origin (point-min)) + (source (buffer-substring (point-min) (point-max))) + (rendered (ebox-surface--render-candidate-node state node)) + (replacement + (ebox-surface--span-patch-lines buffer spans rendered)) + (details + (and replacement + (ebox-surface--span-patch-details + buffer old-snapshot node spans replacement))) + (lines (and details (plist-get details :lines))) + (output (and lines + (ebox-surface--replace-buffer-spans + source origin spans lines)))) + (when output + (ebox-surface--install-span-patch-snapshot + state owner-id old-snapshot details) + output))))))) + +(defun ebox-surface--runtime-index-ready-p (state) + "Return non-nil when STATE carries a complete prepared runtime index." + (let* ((root (plist-get state :root-node)) + (root-id (and root (plist-get root :node-id))) + (nodes (plist-get state :node-table))) + (and (plist-get state :runtime-index-prepared-p) + root-id + (hash-table-p nodes) + (eq root (gethash root-id nodes)) + (hash-table-p (plist-get state :parent-table)) + (hash-table-p (plist-get state :region-id-set)) + (hash-table-p (plist-get state :region-node-table)) + (hash-table-p (plist-get state :region-box-count-table)) + (hash-table-p (plist-get state :region-box-table)) + (hash-table-p (plist-get state :host-ref-table)) + (vectorp (plist-get state :native-node-postorder))))) (defun ebox-surface--finish-runtime-state (state) - "Install post-layout runtime indexes into candidate STATE." - (ebox--render-state-install-index - state (ebox--runtime-index (plist-get state :root-node) t))) + "Install or retain the prepared runtime indexes in candidate STATE." + (unless (ebox-surface--runtime-index-ready-p state) + (ebox--render-state-install-index + state (ebox--runtime-index (plist-get state :root-node) t))) + state) -(defun ebox-surface--role-ids-at (rendered position) - "Return namespaced Ebox role/id pairs at POSITION in RENDERED." +(defun ebox-surface--role-ids-from-properties (properties) + "Return namespaced Ebox role/id pairs from PROPERTIES." (let (roles) - (dolist (region-id - (get-text-property position 'ebox-content-owners rendered)) + (when-let ((region-id + (plist-get properties 'ebox-overflow-foreground-source))) + (push (cons 'overflow-foreground region-id) roles)) + (dolist (region-id (plist-get properties 'ebox-content-owners)) (cl-pushnew (cons 'content-owner region-id) roles :test #'equal)) (dolist (entry ebox-region-types) - (when-let ((region-id - (get-text-property position (cdr entry) rendered))) - (cl-pushnew (cons (car entry) region-id) roles :test #'equal))) + (when-let ((region-id (plist-get properties (cdr entry)))) + (cl-pushnew (cons (car entry) region-id) roles :test #'equal))) (nreverse roles))) +(defun ebox-surface--role-ids-at (rendered position &optional cache) + "Return namespaced Ebox role/id pairs at POSITION in RENDERED. +CACHE, when non-nil, reuses role extraction for equal property plists during +one output projection." + (let* ((properties (text-properties-at position rendered)) + (missing (make-symbol "ebox-role-cache-missing")) + (cached (and cache (gethash properties cache missing)))) + (if (and cache (not (eq cached missing))) + cached + (let ((roles (ebox-surface--role-ids-from-properties properties))) + (when cache + (puthash properties roles cache)) + roles)))) + +(defun ebox-surface--candidate-plan-text (rendered owned-values) + "Return a private snapshot of RENDERED for an owned TP plan. +OWNED-VALUES is the candidate-local provenance registry created by Ebox's +render helpers. TP transfers only mutable values present in this registry; +all other values remain defensively copied." + (tp-text-snapshot + rendered + (and (hash-table-p owned-values) + (lambda (property value) + (and (ebox--render-owned-text-value-p + property value owned-values) + (not (memq property '(face font-lock-face mouse-face)))))))) + (defun ebox-surface--region-role-tags (region-id role-ids) "Return projection tags for REGION-ID from ROLE-IDS." (list :ebox/region-id region-id @@ -872,109 +1271,440 @@ valid because their declarative layout and viewport did not change." (cl-loop for (role . owner) in role-ids when (equal owner region-id) collect role))) -(defun ebox-surface--attach-fragment-ancestors - (context fragment node-id state node-objects attached) - "Attach FRAGMENT in CONTEXT to NODE-ID ancestors from STATE. -NODE-OBJECTS resolves retained objects; ATTACHED prevents duplicate mounts." - (let ((parent-table (plist-get state :parent-table)) - (current (gethash node-id (plist-get state :parent-table)))) - (while current - (let ((object (gethash current node-objects))) - (unless object - (error "Ebox output ancestor has no TP object: %S" current)) - (unless (gethash object attached) - (tp-object-attach-fragment - context object fragment '(:ebox/descendant-output t)) - (puthash object t attached))) - (setq current (gethash current parent-table))))) - -(defun ebox-surface--attach-fragment - (context fragment role-ids state node-objects region-objects) - "Attach FRAGMENT to direct and ancestor Ebox owners in CONTEXT. -ROLE-IDS identifies direct rendered regions. STATE and NODE-OBJECTS extend -that ownership through the Ebox layout tree so a planned ancestor owner may -authorize every descendant output interval without exposing ranges to TP." - (let ((attached (make-hash-table :test 'eq)) - (region-node-table (plist-get state :region-node-table))) +(defun ebox-surface--fragment-owners + (role-ids state node-objects region-objects) + "Return ordered Ebox owner and tag pairs for ROLE-IDS. +Each owner appears at most once for one rendered fragment." + (let ((attached (make-hash-table :test #'eq)) + (region-node-table (plist-get state :region-node-table)) + (parent-table (plist-get state :parent-table)) + owners) (dolist (region-id (delete-dups (mapcar #'cdr role-ids))) (let ((object (gethash region-id region-objects)) (node-id (gethash region-id region-node-table))) (unless (and object node-id) (error "Ebox output references unknown region %S" region-id)) (unless (gethash object attached) - (tp-object-attach-fragment - context object fragment - (ebox-surface--region-role-tags region-id role-ids)) + (push (list object + (ebox-surface--region-role-tags region-id role-ids)) + owners) (puthash object t attached)) - (ebox-surface--attach-fragment-ancestors - context fragment node-id state node-objects attached))))) + (setq node-id (gethash node-id parent-table)) + (while node-id + (let ((ancestor (gethash node-id node-objects))) + (unless ancestor + (error "Ebox output ancestor has no TP object: %S" node-id)) + (unless (gethash ancestor attached) + (push (list ancestor (list :ebox/descendant-output t)) owners) + (puthash ancestor t attached))) + (setq node-id (gethash node-id parent-table))))) + (nreverse owners))) -(defun ebox-surface--fragment-plan - (context parent rendered start end index role-ids - state node-objects region-objects) - "Return one plan below PARENT for RENDERED START..END at INDEX. -ROLE-IDS names the Ebox owners attached through REGION-OBJECTS in CONTEXT." - (let* ((key (cons 'ebox/fragment index)) - (object (tp-object-ensure context parent key 'ebox/fragment)) - (tags (list :ebox/fragment index :ebox/role-ids role-ids))) - (ebox-surface--attach-fragment - context object role-ids state node-objects region-objects) - (tp-surface-plan-create - :key key :kind 'ebox/fragment - :text (substring rendered start end) :tags tags - :capability 'content))) - -(defun ebox-surface--fragment-plans - (context parent rendered state node-objects region-objects) - "Return property-interval plans in CONTEXT for RENDERED below PARENT." +(defun ebox-surface--rendered-fragments (rendered) + "Return an offset index of Ebox-owned property runs in RENDERED. +The complete propertized string remains the sole text storage. Runs keep +START and END offsets into that string instead of allocating one substring +per property interval; paint projection materializes its private copies only +when it needs to mutate them." (let ((position 0) (limit (length rendered)) - intervals previous-role-ids next-role-ids) + (line 0) + (role-cache (make-hash-table :test #'equal)) + fragments previous-role-ids next-role-ids) (while (< position limit) (let ((next (or (next-property-change position rendered limit) limit))) - (push (list :start position :end next - :role-ids - (ebox-surface--role-ids-at rendered position)) - intervals) + (let ((roles (ebox-surface--role-ids-at rendered position role-cache))) + (push (list :text rendered :text-source-p t + :start position :end next + :line line :paint-role-ids roles + :role-ids roles) + fragments)) + (cl-incf line (cl-count ?\n rendered :start position :end next)) (setq position (max next (1+ position))))) - (setq intervals (nreverse intervals)) - (dolist (interval intervals) - (if-let ((roles (plist-get interval :role-ids))) + (setq fragments (nreverse fragments)) + (dolist (fragment fragments) + (if-let ((roles (plist-get fragment :role-ids))) (setq previous-role-ids roles) - (plist-put interval :previous-role-ids previous-role-ids))) - (dolist (interval (reverse (copy-sequence intervals))) - (if-let ((roles (plist-get interval :role-ids))) + (plist-put fragment :previous-role-ids previous-role-ids))) + (dolist (fragment (reverse (copy-sequence fragments))) + (if-let ((roles (plist-get fragment :role-ids))) (setq next-role-ids roles) - (plist-put interval :role-ids + (plist-put fragment :role-ids (delete-dups - (append (plist-get interval :previous-role-ids) - next-role-ids))))) - (cl-loop for interval in intervals - for fragment-index from 0 - collect - (ebox-surface--fragment-plan - context parent rendered - (plist-get interval :start) (plist-get interval :end) - fragment-index (plist-get interval :role-ids) - state node-objects region-objects)))) + (append (copy-sequence + (plist-get fragment :previous-role-ids)) + (copy-sequence next-role-ids)))))) + (cl-loop for fragment in fragments for index from 0 + do (plist-put fragment :key (cons 'ebox/fragment index)) + do (cl-remf fragment :previous-role-ids) + collect fragment))) + +(defun ebox-surface--fragment-metadata (fragments) + "Return FRAGMENTS as offsets into the TP-owned rendered text. +The mutable propertized text is intentionally not retained in Ebox client +state; paint projection reads a defensive snapshot from the published +buffer only when it needs the old text." + (let ((offset 0) + metadata) + (dolist (fragment fragments (nreverse metadata)) + (let* ((source-p (plist-get fragment :text-source-p)) + (text (plist-get fragment :text)) + (length (if source-p + (- (plist-get fragment :end) + (plist-get fragment :start)) + (length text))) + (copy + (cl-loop for (key value) on fragment by #'cddr + unless (eq key :text) + append (list key value)))) + (unless source-p + (plist-put copy :start offset) + (plist-put copy :end (+ offset length))) + (plist-put copy :text-source-p t) + (push copy metadata) + (setq offset (+ offset length)))))) + +(defun ebox-surface--owned-ranges + (context leaf fragments state node-objects region-objects) + "Attach merged owner ranges for FRAGMENTS to candidate LEAF. +Adjacent ranges merge only when both their owner and opaque tags match." + (let ((active (make-hash-table :test #'eq)) + (owner-cache (make-hash-table :test #'equal)) + (offset 0) + ranges) + (dolist (fragment fragments) + (let* ((role-ids (plist-get fragment :role-ids)) + (owners (gethash role-ids owner-cache 'ebox/no-owners)) + (source-p (plist-get fragment :text-source-p)) + (start (if source-p + (plist-get fragment :start) + offset)) + (end (if source-p + (plist-get fragment :end) + (+ offset (length (plist-get fragment :text)))))) + (when (eq owners 'ebox/no-owners) + (setq owners + (ebox-surface--fragment-owners + role-ids state node-objects region-objects)) + (puthash role-ids owners owner-cache)) + (dolist (owner owners) + (let* ((object (nth 0 owner)) + (tags (nth 1 owner)) + (tag-ranges (or (gethash object active) + (let ((table (make-hash-table :test #'equal))) + (puthash object table active) + table))) + (current (gethash tags tag-ranges))) + (if (and current (= (plist-get current :end) start)) + (plist-put current :end end) + (let ((range (list :object object :start start :end end + :tags tags))) + (push range ranges) + (puthash tags range tag-ranges))))) + (setq offset (if source-p end (+ offset (length (plist-get fragment :text))))))) + (tp-object-attach-content-ranges-owned context leaf (nreverse ranges)) + ranges)) (defun ebox-surface--surface-plan - (context surface-root rendered state node-objects region-objects) - "Return CONTEXT's plan below SURFACE-ROOT for RENDERED and STATE. -NODE-OBJECTS and REGION-OBJECTS supply retained ownership for its fragments." + (context surface-root output state node-objects region-objects) + "Return one shared-text TP plan for OUTPUT and STATE. +NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (let* ((fragment-root (tp-object-ensure context surface-root ebox-surface--fragments-key 'ebox/fragments)) - (fragments - (ebox-surface--fragment-plans - context fragment-root rendered state node-objects region-objects))) - (tp-surface-plan-create - :key ebox-surface--root-key :kind 'ebox/surface - :children - (list (tp-surface-plan-create - :key ebox-surface--fragments-key :kind 'ebox/fragments - :children fragments :capability 'content)) - :capability 'content))) + (fragment-data + (if (stringp output) + (ebox-surface--rendered-fragments output) + output)) + (rendered + (if (stringp output) + output + (if (and fragment-data + (plist-get (car fragment-data) :text-source-p)) + (plist-get (car fragment-data) :text) + (apply #'concat + (mapcar (lambda (fragment) + (plist-get fragment :text)) + fragment-data))))) + (text-leaf + (tp-object-ensure context fragment-root + ebox-surface--text-key 'ebox/text))) + (ebox-surface--owned-ranges + context text-leaf fragment-data state node-objects region-objects) + (let* ((plan-text + (ebox-surface--candidate-plan-text + rendered (plist-get state :render-owned-text-values))) + (plan + (tp-surface-plan-create-owned + :key ebox-surface--root-key :kind 'ebox/surface + :children + (list (tp-surface-plan-create-owned + :key ebox-surface--fragments-key :kind 'ebox/fragments + :children + (list (tp-surface-plan-create-owned + :key ebox-surface--text-key :kind 'ebox/text + :text plan-text + :capability 'content)) + :capability 'content)) + :capability 'content))) + (plist-put state :surface-fragments + (ebox-surface--fragment-metadata fragment-data)) + (let ((rendered-length (length rendered))) + (list plan rendered + (list (list :object text-leaf :start 0 :end rendered-length + :props nil :tags nil :leaf t) + (list :object fragment-root :start 0 :end rendered-length + :props nil :tags nil :leaf nil) + (list :object surface-root :start 0 :end rendered-length + :props nil :tags nil :leaf nil))))))) + +(defconst ebox-surface--paint-base-roles + '(content content-owner pt pb pl pr bl br) + "Rendered roles that identify a box's painted border-box surface.") + +(defun ebox-surface--node-depth (state node-id) + "Return NODE-ID depth in Ebox runtime STATE." + (let ((parents (plist-get state :parent-table)) (depth 0)) + (while (setq node-id (and node-id (gethash node-id parents))) + (cl-incf depth)) + depth)) + +(defun ebox-surface--paint-node-chain (state role-ids) + "Return the deepest rendered node and its ancestors for ROLE-IDS." + (let ((region-nodes (plist-get state :region-node-table)) + (parents (plist-get state :parent-table)) deepest deepest-depth) + (dolist (entry role-ids) + (when-let ((node-id (gethash (cdr entry) region-nodes))) + (let ((depth (ebox-surface--node-depth state node-id))) + (when (or (null deepest-depth) (> depth deepest-depth)) + (setq deepest node-id deepest-depth depth))))) + (let (chain) + (while deepest + (push deepest chain) + (setq deepest (gethash deepest parents))) + (nreverse chain)))) + +(defun ebox-surface--foreground-face (color) + "Return the Ebox foreground face for COLOR." + (if (eq color 'ebox/default-foreground) + '(:inherit default) + (list :foreground color))) + +(defun ebox-surface--box-face-contributions (box) + "Return BOX typography and color contributions in render order." + (let (faces) + (when-let ((font (ebox-buffer--font-face box))) + (push font faces)) + (when-let ((color (plist-get box :color))) + (push (ebox-surface--foreground-face color) faces)) + (when-let ((background (plist-get box :bgcolor))) + (push (list :background background) faces)) + (nreverse faces))) + +(defun ebox-surface--region-face-contributions (box roles ancestor-p) + "Return BOX face contributions for ROLES. +ANCESTOR-P means BOX wraps a more specific rendered surface." + (let (faces) + (cond + ((memq 'overflow-foreground roles) + (when-let ((color (plist-get box :color))) + (push (ebox-surface--foreground-face color) faces))) + ((memq 'bl roles) + (push (ebox-buffer-side-border-face + (plist-get box :border-left-color)) faces)) + ((memq 'br roles) + (push (ebox-buffer-side-border-face + (plist-get box :border-right-color)) faces)) + ((or ancestor-p + (cl-intersection roles '(content content-owner pt pb pl pr))) + (setq faces (ebox-surface--box-face-contributions box)))) + (when (and (memq 'bt roles) (plist-get box :border-top-p)) + (setq faces + (append faces + (list (list :overline + (or (plist-get box :border-top-color) t)))))) + (when (and (memq 'bb roles) (plist-get box :border-bottom-p)) + (setq faces + (append + faces + (list + (list :underline + (append '(:position t) + (when-let ((color + (plist-get box :border-bottom-color))) + (list :color color)))))))) + faces)) + +(defun ebox-surface--face-contributions (state role-ids) + "Return semantic Ebox face contributions for ROLE-IDS in STATE." + (let ((nodes (plist-get state :node-table)) result first) + (setq first t) + (dolist (node-id (ebox-surface--paint-node-chain state role-ids)) + (when-let* ((node (gethash node-id nodes)) + (box (ebox-fragment-style-source-node node))) + (let ((region-id (plist-get box :region-id))) + (setq result + (append + result + (ebox-surface--region-face-contributions + box + (cl-loop for (role . owner) in role-ids + when (equal owner region-id) collect role) + (not first)))) + (setq first nil)))) + result)) + +(defun ebox-surface--face-list (face) + "Return FACE as an ordered list of face entries." + (cond ((null face) nil) + ((and (listp face) (keywordp (car-safe face))) (list face)) + ((listp face) (copy-sequence face)) + (t (list face)))) + +(defun ebox-surface--face-value (entries) + "Return canonical face value represented by ENTRIES." + (pcase entries + ('() nil) + (`(,entry) entry) + (_ entries))) + +(defun ebox-surface--face-baseline (face contributions) + "Remove trailing Ebox CONTRIBUTIONS from FACE and return its baseline." + (let* ((entries (ebox-surface--face-list face)) + (count (length contributions)) + (prefix-count (- (length entries) count))) + (if (and (>= prefix-count 0) + (equal (nthcdr prefix-count entries) contributions)) + (ebox-surface--face-value (seq-take entries prefix-count)) + face))) + +(defun ebox-surface--compose-face (baseline contributions) + "Append Ebox CONTRIBUTIONS to BASELINE using Emacs face semantics." + (let ((sample (make-string 1 ?x))) + (when baseline + (put-text-property 0 1 'face baseline sample)) + (dolist (face contributions) + (add-face-text-property 0 1 face t sample)) + (get-text-property 0 'face sample))) + +(defun ebox-surface--fragment-has-region-role-p + (fragment region-id roles) + "Return non-nil when FRAGMENT carries REGION-ID under any of ROLES." + (cl-some (lambda (entry) + (and (equal (cdr entry) region-id) + (memq (car entry) roles))) + (plist-get fragment :paint-role-ids))) + +(defun ebox-surface--horizontal-border-line (fragments region-id bottom-p) + "Return REGION-ID top or BOTTOM-P anchor line from FRAGMENTS." + (let (lines) + (dolist (fragment fragments) + (when (ebox-surface--fragment-has-region-role-p + fragment region-id ebox-surface--paint-base-roles) + (push (plist-get fragment :line) lines))) + (when lines (apply (if bottom-p #'max #'min) lines)))) + +(defun ebox-surface--set-horizontal-border-role + (fragments region-id role enabled) + "Set REGION-ID horizontal border ROLE to ENABLED in FRAGMENTS." + (let ((line (ebox-surface--horizontal-border-line + fragments region-id (eq role 'bb))) + (property (alist-get role ebox-region-types))) + (dolist (fragment fragments) + (let* ((anchor-p + (and enabled (equal (plist-get fragment :line) line) + (ebox-surface--fragment-has-region-role-p + fragment region-id ebox-surface--paint-base-roles))) + (pair (cons role region-id))) + (dolist (key '(:paint-role-ids :role-ids)) + (let ((roles (cl-remove pair (plist-get fragment key) :test #'equal))) + (plist-put fragment key (if anchor-p (append roles (list pair)) roles)))) + (when (> (length (plist-get fragment :text)) 0) + (if-let ((owner + (cdr (cl-find role + (plist-get fragment :paint-role-ids) + :key #'car :test #'eq :from-end t)))) + (put-text-property 0 (length (plist-get fragment :text)) + property owner (plist-get fragment :text)) + (remove-text-properties + 0 (length (plist-get fragment :text)) + (list property nil) (plist-get fragment :text))))))) + fragments) + +(defun ebox-surface--paint-border-topology (fragments state dirty-set) + "Return FRAGMENTS with horizontal border roles updated from STATE." + (let ((nodes (plist-get state :node-table))) + (dolist (dirty dirty-set fragments) + (when-let* ((node (gethash (plist-get dirty :node-id) nodes)) + (box (ebox-fragment-style-source-node node)) + (region-id (plist-get box :region-id))) + (dolist (entry '((:border-top-p . bt) (:border-bottom-p . bb))) + (when (memq (car entry) (plist-get dirty :changed-keys)) + (ebox-surface--set-horizontal-border-role + fragments region-id (cdr entry) (plist-get box (car entry))))))))) + +(defun ebox-surface--repaint-fragment (fragment old-state new-state) + "Return FRAGMENT with Ebox paint recomputed from OLD-STATE to NEW-STATE." + (let* ((text (copy-sequence (plist-get fragment :text))) + (old-roles (plist-get fragment :old-paint-role-ids)) + (new-roles (plist-get fragment :paint-role-ids)) + (old-faces (ebox-surface--face-contributions old-state old-roles)) + (new-faces (ebox-surface--face-contributions new-state new-roles)) + (baseline (and (> (length text) 0) + (ebox-surface--face-baseline + (get-text-property 0 'face text) old-faces))) + (unchanged (and (equal old-roles new-roles) + (equal old-faces new-faces))) + (face (and (not unchanged) + (ebox-surface--compose-face baseline new-faces)))) + (when (and (not unchanged) (> (length text) 0)) + (if face + (put-text-property 0 (length text) 'face face text) + (remove-text-properties 0 (length text) '(face nil) text))) + (plist-put fragment :text text) + (cl-remf fragment :old-paint-role-ids) + fragment)) + +(defun ebox-surface--paint-fragments + (old-state new-state dirty-set old-text) + "Return OLD-STATE fragments repainted for NEW-STATE and DIRTY-SET. +OLD-TEXT is a defensive snapshot of the published TP content." + (unless (stringp old-text) + (error "Ebox paint projection has no published TP text")) + (let ((fragments + (mapcar + (lambda (fragment) + (let* ((copy (copy-tree fragment)) + (text (substring old-text + (plist-get fragment :start) + (plist-get fragment :end)))) + (plist-put copy :text text) + (plist-put copy :start 0) + (plist-put copy :end (length text)) + (cl-remf copy :text-source-p) + (plist-put copy :old-paint-role-ids + (copy-tree (plist-get fragment :paint-role-ids))) + copy)) + (plist-get old-state :surface-fragments)))) + (unless fragments + (error "Ebox paint projection has no published fragment topology")) + (ebox-surface--paint-border-topology fragments new-state dirty-set) + (mapcar (lambda (fragment) + (ebox-surface--repaint-fragment fragment old-state new-state)) + fragments))) + +(defun ebox-surface--refresh-paint-snapshots (state dirty-set) + "Refresh paint-significant layout snapshots in STATE for DIRTY-SET." + (let ((nodes (plist-get state :node-table)) + (snapshots (plist-get state :layout-snapshots))) + (when (hash-table-p snapshots) + (dolist (dirty dirty-set) + (when-let* ((node-id (plist-get dirty :node-id)) + (node (gethash node-id nodes)) + (snapshot (gethash node-id snapshots))) + (plist-put snapshot :style-signature + (ebox-fragment-node-style-signature node))))) + state)) (defun ebox-surface--apply-state-overrides (state overrides) "Apply plist OVERRIDES to candidate runtime STATE and return STATE." @@ -1006,12 +1736,43 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership for its fragments." (make-hash-table :test 'eq))) (ebox--viewport-dependent-node-id-axes root))) +(defun ebox-surface--projection-state + (root previous-state state-overrides projection-kind + stylesheet-active-p cascade-required-p axes viewport-width + viewport-height display-signature scroll-offsets) + "Build candidate STATE with the mounted context values installed." + (let ((state + (ebox-surface--apply-state-overrides + (if (eq projection-kind 'paint) + (copy-sequence previous-state) + (ebox--new-buffer-render-state root)) + (copy-sequence state-overrides)))) + (plist-put state :root-node root) + (plist-put state :cascade-active-p stylesheet-active-p) + (plist-put state :cascade-required-p cascade-required-p) + (when axes + (plist-put state :viewport-width viewport-width) + (plist-put state :viewport-height viewport-height) + (plist-put state :display-signature display-signature) + (plist-put state :viewport-dependent-node-id-axes axes) + (plist-put state :viewport-dependent-node-ids + (delete-dups (copy-sequence + (append (car axes) (cdr axes))))) + (plist-put state :viewport-dependent-node-ids-ready t) + (ebox-surface--apply-scroll-offsets root scroll-offsets)) + state)) + (defun ebox-surface--project (context source previous-state preserve-identities-p state-overrides - signals) + signals projection-kind source-isolated-p source-path-copied-p) "Project SOURCE in CONTEXT using PREVIOUS-STATE and identity policy. PRESERVE-IDENTITIES-P retains existing Ebox node and region identities. -SIGNALS, when non-nil, supplies the mounted surface's host context." +SIGNALS, when non-nil, supplies the mounted surface's host context. +PROJECTION-KIND may select a proven non-spatial projection. +SOURCE-ISOLATED-P means SOURCE is a private candidate owned by the current +publication attempt; it is safe to clear its transient attachments in place. +SOURCE-PATH-COPIED-P means SOURCE shares untouched Ebox nodes with the +published runtime and must be consumed without clearing those shared nodes." (if (stringp source) (let ((object (tp-object-ensure context nil ebox-surface--root-key @@ -1021,65 +1782,136 @@ SIGNALS, when non-nil, supplies the mounted surface's host context." (tp-surface-plan-create :key ebox-surface--root-key :kind 'ebox/string :text source :capability 'content))) - (let* ((root (ebox-surface--candidate-root - source previous-state preserve-identities-p)) - (axes (and signals (ebox-surface--context-axes root))) - (viewport-width - (and signals - (funcall - (if (car axes) #'tp-signal-read #'tp-signal-peek) - (ebox-surface--signals-viewport-width signals)))) - (viewport-height - (and signals - (funcall - (if (cdr axes) #'tp-signal-read #'tp-signal-peek) - (ebox-surface--signals-viewport-height signals)))) - (display-signature - (and signals - (tp-signal-read - (ebox-surface--signals-display signals)))) - (scroll-offsets - (and signals - (tp-signal-peek - (ebox-surface--signals-scroll signals)))) - (state - (ebox-surface--apply-state-overrides - (ebox--new-buffer-render-state root) - (copy-sequence state-overrides)))) - (when signals - (plist-put state :viewport-width viewport-width) - (plist-put state :viewport-height viewport-height) - (plist-put state :display-signature display-signature) - (plist-put state :viewport-dependent-node-id-axes axes) - (plist-put state :viewport-dependent-node-ids - (delete-dups - (copy-sequence (append (car axes) (cdr axes))))) - (plist-put state :viewport-dependent-node-ids-ready t) - (ebox-surface--apply-scroll-offsets root scroll-offsets)) - (let ((projection (ebox-surface--projection-start context root))) - (plist-put state :region-box-table (make-hash-table :test 'equal)) - (let ((rendered (ebox-surface--render-candidate state))) - (when (and signals - (hash-table-p (plist-get state :scroll-state-table)) - (> (hash-table-count - (plist-get state :scroll-state-table)) - 0)) - (tp-signal-read (ebox-surface--signals-scroll signals))) - (setq state (ebox-surface--finish-runtime-state state)) - (ebox-surface--projection-result - context projection state rendered)))))) + (let* ((stylesheet-active-p (ebox-style-cascade-active-p)) + (root (cond + (source-path-copied-p source) + (source-isolated-p + (ebox-surface--clear-runtime-attachments source)) + (t + (ebox-surface--candidate-root + source preserve-identities-p)))) + (state-overrides + (if (and (memq projection-kind + '(viewport-reflow viewport-reflow-mixed-scroll)) + (not source-isolated-p) + (not source-path-copied-p)) + (ebox-surface--isolated-viewport-overrides state-overrides) + state-overrides)) + (inline-inheritance-required-p + (ebox-surface--inline-inheritance-required-p root)) + (cascade-required-p + (or stylesheet-active-p inline-inheritance-required-p)) + (style-required-p + (or cascade-required-p + (plist-get previous-state :cascade-active-p) + (plist-get previous-state :cascade-required-p))) + (projection + (ebox-surface--projection-start + context root style-required-p projection-kind previous-state))) + (unless (or source-isolated-p source-path-copied-p) + (ebox-surface--reconcile-candidate root previous-state)) + (let* ((axes (and signals (ebox-surface--context-axes root))) + (viewport-width + (and signals + (funcall + (if (car axes) #'tp-signal-read #'tp-signal-peek) + (ebox-surface--signals-viewport-width signals)))) + (viewport-height + (and signals + (funcall + (if (cdr axes) #'tp-signal-read #'tp-signal-peek) + (ebox-surface--signals-viewport-height signals)))) + (display-signature + (and signals + (tp-signal-read + (ebox-surface--signals-display signals)))) + (scroll-offsets + (and signals + (tp-signal-peek + (ebox-surface--signals-scroll signals)))) + (state + (ebox-surface--projection-state + root previous-state state-overrides projection-kind + stylesheet-active-p cascade-required-p axes viewport-width + viewport-height display-signature scroll-offsets))) + (when (eq projection-kind 'span-patch) + (ebox-surface--attach-retained-node-objects state)) + (if (eq projection-kind 'paint) + (let* ((dirty-set (plist-get state-overrides :paint-dirty-set)) + (buffer (and signals + (ebox-surface--signals-buffer signals))) + (old-text + (and (buffer-live-p buffer) + (with-current-buffer buffer + (save-restriction + (widen) + (buffer-substring (point-min) (point-max))))))) + (setq state (ebox-surface--finish-runtime-state state)) + (ebox-surface--refresh-paint-snapshots state dirty-set) + (cl-remf state :paint-dirty-set) + (ebox-surface--projection-result + context projection state + (ebox-surface--paint-fragments + previous-state state dirty-set old-text))) + (plist-put state :region-box-table (make-hash-table :test 'equal)) + (let ((rendered + (if (eq projection-kind 'span-patch) + (if-let ((span-output + (ebox-surface--span-patch-output + (ebox-surface--signals-buffer signals) state))) + span-output + (setq projection + (ebox-surface--complete-projection + context root style-required-p projection)) + (setq state + (ebox-surface--projection-state + root previous-state state-overrides nil + stylesheet-active-p cascade-required-p axes + viewport-width viewport-height display-signature + scroll-offsets)) + (plist-put state :region-box-table + (make-hash-table :test 'equal)) + (ebox-surface--render-candidate state)) + (ebox-surface--render-candidate state)))) + (when (and signals + (hash-table-p (plist-get state :scroll-state-table)) + (> (hash-table-count + (plist-get state :scroll-state-table)) + 0)) + (tp-signal-read (ebox-surface--signals-scroll signals))) + (setq state (ebox-surface--finish-runtime-state state)) + (when (memq projection-kind + '(viewport-reflow viewport-reflow-mixed-scroll)) + ;; The reflow source is copied at this boundary. Attach the + ;; retained objects only after its candidate indexes exist; + ;; attaching before that point would mutate the published + ;; node table through the old state's indexes. + (ebox-surface--attach-retained-node-objects state)) + (ebox-surface--projection-result + context projection state rendered))))))) (defun ebox-surface-producer (source &optional previous-state preserve-identities-p state-overrides - signals) + signals projection-kind source-isolated-p source-path-copied-p) "Return a TP producer for Ebox SOURCE and optional PREVIOUS-STATE. PRESERVE-IDENTITIES-P retains existing Ebox node and region identities. STATE-OVERRIDES augments the candidate Ebox runtime state. SIGNALS carries -the mounted surface context; one-shot string materialization omits it." +the mounted surface context; one-shot string materialization omits it. +PROJECTION-KIND may select a proven non-spatial projection. +SOURCE-ISOLATED-P means SOURCE is already an internally isolated runtime +candidate and must not be copied or reconciled again. SOURCE-PATH-COPIED-P +means SOURCE shares untouched Ebox nodes with the published runtime and must +also bypass candidate copying and reconciliation without clearing those nodes." (lambda (context) - (ebox-surface--project - context source previous-state preserve-identities-p state-overrides - signals))) + (let ((buffer (and signals (ebox-surface--signals-buffer signals)))) + (if (buffer-live-p buffer) + (with-current-buffer buffer + (ebox-surface--project + context source previous-state preserve-identities-p state-overrides + signals projection-kind source-isolated-p source-path-copied-p)) + (ebox-surface--project + context source previous-state preserve-identities-p state-overrides + signals projection-kind source-isolated-p source-path-copied-p))))) (provide 'ebox-surface) diff --git a/ebox-tree.el b/ebox-tree.el index 361e9aa..314fde5 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -10,8 +10,8 @@ (require 'cl-lib) (require 'subr-x) +(require 'ecss-selector) (require 'ebox-style) -(require 'tp-style) (defconst ebox--default-display '(block flow) "Default CSS-like display for a plain Ebox box.") @@ -432,8 +432,14 @@ and all explicit identities are compared with `equal'. Return ROOT on success." "Child references excluded from node-local declarative signatures.") (defconst ebox-tree--excluded-source-keys - (append ebox-tree--runtime-source-keys ebox-tree--child-source-keys) - "Runtime and child keys excluded from node-local source comparison.") + (append ebox-tree--runtime-source-keys + ebox-tree--child-source-keys + '(:ebox-style-declarations :ebox-style-overrides + :ebox-computed-style :ebox-style-wrapper + :ebox-style-generated-wrapper)) + "Non-rendering keys excluded from node-local source comparison. +Cascade declarations and computed-style records are side state; their +projected Ebox longhands carry the actual paint and layout consequences.") (defconst ebox-tree-metadata-source-keys '(:key :id :class :host-ref) @@ -644,16 +650,20 @@ candidate runtime index agree." :selector-type-table type-table))) (defun ebox-tree-node-subject (node) - "Return a detached TP selector subject representing NODE." - (tp-subject-create - :type (ebox-tree-node-selector-type node) + "Return a detached ECSS selector subject representing NODE." + (ecss-subject-create + :type (ebox-tree-metadata-string (ebox-tree-node-selector-type node)) :id (ebox-tree-node-id node) :classes (ebox-tree-node-classes node) - :attributes (ebox-tree-node-attributes node) - :state (ebox-tree-node-state node))) + :attributes + (mapcar (lambda (attribute) + (cons (substring (symbol-name (car attribute)) 1) + (cdr attribute))) + (ebox-tree-node-attributes node)) + :states (ebox-tree-node-state node))) (defun ebox-tree-subject-index (root) - "Return TP subjects and semantic node paths below ROOT in document order." + "Return ECSS subjects and semantic node paths below ROOT in document order." (let ((node-subject-table (make-hash-table :test #'eq)) entries) (cl-labels @@ -664,7 +674,7 @@ candidate runtime index agree." (puthash node subject node-subject-table) (push (list :node node :path current-path :subject subject) entries) - (tp-subject-set-children + (ecss-subject-set-children subject (delq nil (mapcar (lambda (child) (visit child current-path)) @@ -676,13 +686,13 @@ candidate runtime index agree." :node-subject-table node-subject-table))))) (defun ebox-tree-subject-for-path (path) - "Return a TP subject chain for the root-to-node Ebox PATH." + "Return an ECSS subject chain for the root-to-node Ebox PATH." (let (parent subject) (dolist (node path) (when (and (listp node) (not (stringp node))) (setq subject (ebox-tree-node-subject node)) (when parent - (tp-subject-set-children parent (list subject))) + (ecss-subject-set-children parent (list subject))) (setq parent subject))) subject)) diff --git a/ebox.el b/ebox.el index 2b4d32f..3dbb3e0 100644 --- a/ebox.el +++ b/ebox.el @@ -2,7 +2,7 @@ ;; Author: ebox contributors ;; Version: 2.0.0 -;; Package-Requires: ((emacs "29.1") (tp "1.0.0")) +;; Package-Requires: ((emacs "29.1") (ecss "0.1.0") (tp "1.0.0")) ;; Keywords: ui, graphics, convenience ;; URL: https://github.com/ginqi7/ebox @@ -389,6 +389,8 @@ caller, such as the Playground preview, renders against a concrete window.") :color nil :bgcolor nil + :font nil :font-family nil :font-height nil + :font-weight nil :font-slant nil :text-align left :vertical-align top ;; supports: 'visible / 'hidden / 'scroll :overflow scroll @@ -415,8 +417,21 @@ PLIST is a property list that overrides the defaults." "Retrieve PROPERTY from BOX." (plist-get box property)) -(defsubst ebox-put (box property value) - "Set PROPERTY to VALUE in BOX and return the modified box." +(defun ebox-put (box property value) + "Set PROPERTY to VALUE in BOX and return the modified box. +Registered style properties also update BOX's canonical ECSS declarations so a +later cascade computes from the public mutation instead of stale source data." + (when-let ((declaration + (ebox-style-compile-declarations (list property value)))) + (plist-put + box :ebox-style-declarations + (ebox-style-merge-declarations + (plist-get box :ebox-style-declarations) declaration)) + (when (plist-get box :ebox-style-wrapper) + (plist-put + box :ebox-style-overrides + (ebox-style-merge-declarations + (plist-get box :ebox-style-overrides) declaration)))) (plist-put box property value)) ;;;###autoload @@ -761,28 +776,6 @@ indexes. This prepares component identity without exposing a component API." "Build an Ebox node from an ETML-style list DSL." (ebox-dsl-build dsl)) -(defmacro ebox--to-buffer (buffer-or-name switch-fn &rest body) - "Render into BUFFER-OR-NAME using SWITCH-FN, executing BODY." - (declare (indent 2)) - `(let ((buffer (get-buffer-create ,buffer-or-name))) - (with-current-buffer buffer - (let ((inhibit-read-only t)) - (erase-buffer) - ,@body) - (local-set-key "q" (lambda () (interactive) - (local-unset-key "q") - (quit-window))) - (read-only-mode 1) - (funcall ,switch-fn buffer)))) - -(defmacro ebox-pop-to-buffer (buffer-or-name &rest body) - (declare (indent defun)) - `(ebox--to-buffer ,buffer-or-name #'pop-to-buffer ,@body)) - -(defmacro ebox-switch-to-buffer (buffer-or-name &rest body) - (declare (indent defun)) - `(ebox--to-buffer ,buffer-or-name #'switch-to-buffer ,@body)) - ;;; ============================================================ ;;; Property Rules: 配置驱动的 Shorthand/Longhand 映射 ;;; ============================================================ @@ -859,11 +852,13 @@ Supports: t (default fg), nil, string, (light . dark) cons." (defun ebox-create (&rest plist) "Create a box from PLIST. Supports all shorthand and longhand properties. Properties are expanded recursively according to `ebox-property-rules'." - (let ((expanded (ebox--expand-plist plist)) + (let ((declarations (ebox-style-compile-declarations plist)) + (expanded (ebox--expand-plist plist)) (controlled-scroll-offset-p (plist-member plist :scroll-offset))) (let ((box (ebox--longhand-create expanded))) (plist-put box :ebox-type 'box) (plist-put box :display '(block flow)) + (plist-put box :ebox-style-declarations declarations) (when controlled-scroll-offset-p (plist-put box :ebox-scroll-offset-controlled-p t)) box))) @@ -1318,9 +1313,13 @@ remain transaction-local." (defun ebox--scroll-clear-state (region-id) "Remove scroll state for REGION-ID." - (ebox--scroll-cancel-idle-prefetch region-id) - (remhash region-id ebox--scroll-global-state) - (ebox--record-render-cache-scroll-region region-id)) + (let ((had-state (gethash region-id ebox--scroll-global-state))) + (ebox--scroll-cancel-idle-prefetch region-id) + (remhash region-id ebox--scroll-global-state) + ;; A clear of an already-empty slot has no candidate-visible effect and + ;; must not make an otherwise pure fragment look generation-sensitive. + (when had-state + (ebox--record-render-cache-scroll-region region-id)))) (defun ebox--scroll-state-rendered-visible-window (state) "Return STATE's current rendered visible slice, including text properties." @@ -2276,7 +2275,7 @@ publishes the prepared rendered-body entry plus its derived root templates." (progn (ebox--invalidate-reflow-prewarm-scratch-signatures scratch region-id) - (ebox-put box-copy :width target-width) + (plist-put box-copy :width target-width) (ebox--render-reflow-prewarm-scratch buffer state revision scratch (plist-get state :viewport-width) @@ -2822,7 +2821,8 @@ inside the old prefix, so a line-slide never mixes two layout versions." :patch-ops '(span-patch) :owner-id owner-id :owner-ids (list owner-id) - :scroll-state-transaction t))))) + :scroll-state-transaction t)) + t))) (_staged-cache (ebox--surface-restore-staged-scroll-cache commit-input region-id staged-scroll-state)) @@ -2840,7 +2840,9 @@ inside the old prefix, so a line-slide never mixes two layout versions." scope-node-ids (plist-get commit-input :report-base) state-overrides nil nil - (and (plist-get transition :schedule-prefetch) 0)))) + (and (plist-get transition :schedule-prefetch) 0) + (plist-get commit-input :projection-kind) + t))) (ignore surface) (plist-get transition :result)))) @@ -3276,8 +3278,8 @@ Defaults to the current buffer." (:background-color :bgcolor) (_ property))) -(defun ebox--region-update-analysis (box expanded) - "Return the effective change analysis for BOX and EXPANDED properties." +(defun ebox--region-update-analysis (box expanded declarations) + "Return change analysis for BOX, EXPANDED fields, and DECLARATIONS." (let (content-seen content-value style-changed-p @@ -3295,7 +3297,9 @@ Defaults to the current buffer." (t (push key unknown-keys)))) (list :changed-p - (or style-changed-p + (or (not (equal declarations + (plist-get box :ebox-style-declarations))) + style-changed-p (and content-seen (not (equal (ebox-get box :content) content-value)))) :unknown-keys (nreverse unknown-keys)))) @@ -3309,23 +3313,40 @@ Defaults to the current buffer." #'ebox--schedule-runtime-prewarm-after-batch-flush t) -(defun ebox--surface-region-candidate-root (buffer state) - "Return BUFFER's batch candidate or a fresh copy from STATE." - (or (ebox-incremental-surface-batch-root buffer) - (ebox-tree-copy-node-structure (plist-get state :root-node)))) +(defun ebox--surface-region-candidate-root + (buffer state region-id &optional path-copy-p) + "Return BUFFER's candidate root for REGION-ID. +Use a narrow copy-on-write path when PATH-COPY-P is proven safe; active batch +candidates are copied again before a second mutation so shared published nodes +cannot be changed through the unpublished batch root." + (let ((batch-root (ebox-incremental-surface-batch-root buffer))) + (or (and batch-root + (ebox-tree-copy-node-structure batch-root)) + (and path-copy-p + (ebox-incremental-surface-region-candidate-root + buffer region-id)) + (ebox-tree-copy-node-structure (plist-get state :root-node))))) -(defun ebox--surface-region-apply-props (root region-id expanded) - "Apply EXPANDED properties to REGION-ID in ROOT and return changed keys." +(defun ebox--surface-region-apply-props + (root region-id expanded declarations) + "Apply DECLARATIONS then EXPANDED fields to REGION-ID in ROOT." (let ((box (ebox--root-region-box root region-id)) changed-keys) (unless box (user-error "Ebox region handle no longer resolves to a box")) + (let ((declarations-changed + (not (equal declarations + (plist-get box :ebox-style-declarations))))) + (when declarations-changed + (plist-put box :ebox-style-declarations declarations)) (cl-loop for (key value) on expanded by #'cddr for target-key = (ebox--region-update-normalize-property key) when (or (eq target-key :content) (memq target-key ebox--region-update-longhand-props)) - unless (equal (ebox-get box target-key) value) - do (ebox-put box target-key value) - and do (push target-key changed-keys)) + when (or (not (equal (ebox-get box target-key) value)) + (and declarations-changed + (ebox-style-property key))) + do (plist-put box target-key value) + and do (push target-key changed-keys))) (nreverse changed-keys))) (defun ebox--surface-region-no-op-report (state region-id handle) @@ -3501,9 +3522,9 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (ebox--surface-region-scroll-report scroll-context changed-keys))) (commit-input - (ebox--surface-prepare-scroll-changes + (ebox--surface-prepare-scroll-changes (ebox-incremental-prepare-scoped-commit - buffer candidate-root report-overrides) + buffer candidate-root report-overrides t) changes)) (scope-node-ids (let* ((planned @@ -3524,7 +3545,9 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." scope-node-ids (plist-get commit-input :report-base) (plist-get commit-input :state-overrides) - nil nil))) + nil nil nil + (plist-get commit-input :projection-kind) + t))) (plist-get (tp-surface-client-state surface) :last-update-report)))) (defun ebox--publish-surface-region-batch (buffer candidate-root pending) @@ -3546,16 +3569,20 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (defun ebox--surface-region-update-target (buffer region-id handle props) "Apply PROPS to BUFFER REGION-ID through TP for optional HANDLE." (let* ((state (ebox--buffer-render-state buffer)) - (candidate-root - (ebox--surface-region-candidate-root buffer state)) + (published-root (plist-get state :root-node)) (candidate-box - (or (ebox--root-region-box candidate-root region-id) + (or (ebox--root-region-box published-root region-id) (user-error "Ebox region handle no longer resolves to a box"))) (expanded (ebox--expand-plist props)) + (declarations + (ebox-style-merge-declarations + (plist-get candidate-box :ebox-style-declarations) + (ebox-style-compile-declarations props))) (old-style-values (and (plist-member expanded :overflow) (list :overflow (ebox-get candidate-box :overflow)))) - (analysis (ebox--region-update-analysis candidate-box expanded))) + (analysis + (ebox--region-update-analysis candidate-box expanded declarations))) (dolist (key (plist-get analysis :unknown-keys)) (message "ebox-region-update: unknown key %S (ignored)" key)) (if (not (plist-get analysis :changed-p)) @@ -3566,9 +3593,23 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (eq (ebox--region-update-normalize-property (car expanded)) :scroll-offset)) (ebox--surface-scroll-to-offset buffer region-id (cadr expanded)) - (let* ((changed-keys + (let* ((content-only-p + (and (= (length expanded) 2) + (eq (ebox--region-update-normalize-property + (car expanded)) + :content) + (stringp (cadr expanded)) + (numberp (ebox-get candidate-box :width)) + (null (plist-get candidate-box :ebox-content-node)) + (not (ebox-style-cascade-active-p)) + (not (plist-get state :cascade-required-p)) + (null (plist-get state :scroll-region-ids)))) + (candidate-root + (ebox--surface-region-candidate-root + buffer state region-id content-only-p)) + (changed-keys (ebox--surface-region-apply-props - candidate-root region-id expanded)) + candidate-root region-id expanded declarations)) (dirty-kind (ebox--region-update-dirty-kind changed-keys)) (constraint-change @@ -3817,7 +3858,9 @@ Return the successful publication report stored by `ebox-buffer-update-report'." buffer source scope-node-ids (plist-get commit-input :report-base) (plist-get commit-input :state-overrides) - callback) + callback nil nil + (plist-get commit-input :projection-kind) + t) (ebox-surface-mount-buffer buffer source (plist-get commit-input :report-base) @@ -3858,7 +3901,7 @@ through dirty-set and patch-set execution before falling back to root rerender." ;;;###autoload (defun ebox-display-buffer (buffer-or-name node) - "Render NODE to BUFFER-OR-NAME and display the buffer with `pop-to-buffer'." + "Render NODE to BUFFER-OR-NAME through TP and display the buffer." (declare (indent 1)) (delete-other-windows) (switch-to-buffer (ebox-render-to-buffer buffer-or-name node))) @@ -3881,7 +3924,6 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-flex-item ebox-host-ref-bounds ebox-host-ref-position - ebox-pop-to-buffer ebox-region-ids ebox-region-update ebox-render @@ -3902,7 +3944,6 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-selector-update-buffer ebox-spacer ebox-stack - ebox-switch-to-buffer ebox-update-selector ebox-wheel-scroll-down ebox-wheel-scroll-up) @@ -3969,7 +4010,7 @@ through dirty-set and patch-set execution before falling back to root rerender." ;; → replace one runtime node logically ;; ebox-candidate-replace-host-ref cand ref root &optional old-key new-key ;; → replace one semantic host boundary -;; ebox-display-buffer buf box → render and display with pop-to-buffer +;; ebox-display-buffer buf box → render through TP and display the buffer ;; ebox-host-ref-position buf ref → first live position for :host-ref ;; ebox-host-ref-bounds buf ref → live (START . END) bounds for :host-ref ;; diff --git a/examples/ebox-basic-examples.el b/examples/ebox-basic-examples.el deleted file mode 100644 index 6077536..0000000 --- a/examples/ebox-basic-examples.el +++ /dev/null @@ -1,40 +0,0 @@ -;;; ebox-basic-examples.el --- Basic Ebox examples -*- lexical-binding: t; -*- - -;;; Commentary: - -;; Load this file after adding the repository root to `load-path', then run -;; `M-x ebox-basic-examples-open'. The example intentionally uses `:wrap-mode -;; kp' to exercise the optional ekp-backed wrapping path while still loading -;; cleanly when ekp is unavailable. - -;;; Code: - -(require 'ebox) - -(defconst ebox-basic-examples-long-text - "作为可嵌入 Emacs buffer 的文本 UI 组件,Ebox 负责把普通字符串渲染成稳定的像素宽度盒子。这个示例混合中文、English words 和标点,用来验证 CJK 换行、kp wrap、padding、margin 与 border 在同一个盒子里的组合效果。") - -(defun ebox-basic-examples-build () - "Return the basic example layout node." - (ebox-build - `(box :content ,ebox-basic-examples-long-text - :margin 2 - :margin-top 1 - :padding (0 2) - :width (410) - :border "gray" - :wrap-mode kp))) - -(defun ebox-basic-examples-render () - "Render the basic example to a propertized string." - (ebox-render (ebox-basic-examples-build))) - -;;;###autoload -(defun ebox-basic-examples-open () - "Open the basic Ebox example buffer." - (interactive) - (ebox-display-buffer "*ebox-basic-examples*" - (ebox-basic-examples-build))) - -(provide 'ebox-basic-examples) -;;; ebox-basic-examples.el ends here diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index 91f6f80..7779a70 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -20,6 +20,16 @@ typedef struct { size_t len; } ebox_native_bytes; +typedef struct { + int64_t base; + int64_t hypothetical; + int64_t min_main; + int64_t max_main; + double grow; + double shrink; + bool max_known; +} ebox_native_flex_item; + typedef bool (*ebox_readiness_signal_fn)(int32_t fd); typedef void (*ebox_readiness_close_fn)(int32_t fd); @@ -57,6 +67,13 @@ extern bool ebox_native_layout_ready(void); extern bool ebox_native_render_proof(const unsigned char *payload, size_t payload_len, ebox_native_bytes *output); +extern bool ebox_native_flex_size_lines(const ebox_native_flex_item *items, + size_t item_count, + const size_t *line_offsets, + size_t line_count, + int64_t main_limit, + int64_t main_gap, + ebox_native_bytes *output); static emacs_value ebox_nil(emacs_env *env) @@ -140,7 +157,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (void) data; - static const char version[] = "7:2:9"; + static const char version[] = "8:3:9"; return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); } @@ -303,6 +320,125 @@ ebox_module_render_proof(emacs_env *env, ptrdiff_t nargs, emacs_value *args, return result; } +static bool +ebox_extract_flex_integer(emacs_env *env, emacs_value value, int64_t *output) +{ + *output = (int64_t) env->extract_integer(env, value); + return env->non_local_exit_check(env) == emacs_funcall_exit_return; +} + +static bool +ebox_extract_flex_float(emacs_env *env, emacs_value value, double *output) +{ + *output = env->extract_float(env, value); + return env->non_local_exit_check(env) == emacs_funcall_exit_return; +} + +static emacs_value +ebox_module_flex_size_lines(emacs_env *env, ptrdiff_t nargs, + emacs_value *args, void *data) +{ + (void) nargs; + (void) data; + ptrdiff_t packed_size = env->vec_size(env, args[0]); + if (env->non_local_exit_check(env) != emacs_funcall_exit_return || + packed_size < 1) { + return ebox_nil(env); + } + int64_t line_count_value = 0; + if (!ebox_extract_flex_integer(env, env->vec_get(env, args[0], 0), + &line_count_value) || + line_count_value < 0 || line_count_value > 2048) { + return ebox_nil(env); + } + size_t line_count = (size_t) line_count_value; + size_t *line_offsets = calloc(line_count + 1, sizeof *line_offsets); + ebox_native_flex_item *items = NULL; + size_t item_capacity = 0; + size_t item_count = 0; + ptrdiff_t cursor = 1; + bool valid = line_offsets != NULL; + for (size_t line = 0; valid && line < line_count; line++) { + int64_t line_length_value = 0; + if (cursor >= packed_size || + !ebox_extract_flex_integer(env, env->vec_get(env, args[0], cursor++), + &line_length_value) || + line_length_value < 0 || line_length_value > 8192) { + valid = false; + break; + } + size_t line_length = (size_t) line_length_value; + if (item_count > 8192 - line_length) { + valid = false; + break; + } + if (item_count + line_length > item_capacity) { + size_t next_capacity = item_count + line_length; + ebox_native_flex_item *next = realloc( + items, next_capacity * sizeof *items); + if (next == NULL) { + valid = false; + break; + } + items = next; + item_capacity = next_capacity; + } + line_offsets[line] = item_count; + for (size_t item = 0; item < line_length && valid; item++) { + ebox_native_flex_item *target = &items[item_count++]; + if (cursor + 6 > packed_size || + !ebox_extract_flex_integer(env, env->vec_get(env, args[0], cursor++), + &target->base) || + !ebox_extract_flex_integer(env, env->vec_get(env, args[0], cursor++), + &target->hypothetical) || + !ebox_extract_flex_integer(env, env->vec_get(env, args[0], cursor++), + &target->min_main)) { + valid = false; + break; + } + emacs_value max_value = env->vec_get(env, args[0], cursor++); + target->max_known = env->is_not_nil(env, max_value); + if (env->non_local_exit_check(env) != emacs_funcall_exit_return || + (target->max_known && + !ebox_extract_flex_integer(env, max_value, &target->max_main)) || + !ebox_extract_flex_float( + env, env->vec_get(env, args[0], cursor++), &target->grow) || + !ebox_extract_flex_float( + env, env->vec_get(env, args[0], cursor++), &target->shrink)) { + valid = false; + break; + } + } + line_offsets[line + 1] = item_count; + } + int64_t main_limit = 0; + int64_t main_gap = 0; + if (cursor != packed_size || + !ebox_extract_flex_integer(env, args[1], &main_limit) || + !ebox_extract_flex_integer(env, args[2], &main_gap)) { + valid = false; + } + if (!valid) { + free(items); + free(line_offsets); + return ebox_nil(env); + } + ebox_native_bytes output = {0}; + bool ok = ebox_native_flex_size_lines( + items, item_count, line_offsets, line_count, main_limit, main_gap, + &output); + free(items); + free(line_offsets); + if (!ok || output.data == NULL) { + ebox_native_bytes_free(output); + return ebox_nil(env); + } + emacs_value result = env->make_unibyte_string( + env, (const char *) output.data, (ptrdiff_t) output.len); + ebox_native_bytes_free(output); + return result; +} + static emacs_value ebox_module_confirm_frame(emacs_env *env, ptrdiff_t nargs, emacs_value *args, void *data) @@ -466,6 +602,9 @@ ebox_module_init_impl(struct emacs_runtime *runtime) ebox_bind_function(env, "ebox-native--module-render-proof", 1, 1, ebox_module_render_proof, "Render one private native proof synchronously.") || + ebox_bind_function(env, "ebox-native--module-flex-size-lines", 3, 3, + ebox_module_flex_size_lines, + "Calculate pure native Flex geometry lines.") || ebox_bind_function(env, "ebox-native--module-confirm-frame", 4, 4, ebox_module_confirm_frame, "Confirm one published native layout frame.") || diff --git a/native/src/lib.rs b/native/src/lib.rs index 17f824a..37bc982 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -55,6 +55,266 @@ impl NativeBytes { } } +/// One already-normalized flex item crossing the pure geometry boundary. +/// +/// Ebox owns the node identity, style cascade, display measurement, and +/// rendering. The native kernel receives only integer geometry facts and +/// numeric distribution factors, then returns target main-axis sizes. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct NativeFlexItem { + pub base: i64, + pub hypothetical: i64, + pub min_main: i64, + pub max_main: i64, + pub grow: f64, + pub shrink: f64, + pub max_known: bool, +} + +const MAX_FLEX_ITEMS: usize = 8192; +const MAX_FLEX_LINES: usize = 2048; + +fn flex_distribute(amount: i64, weights: &[f64]) -> Vec { + let amount = amount.max(0); + let total: f64 = weights.iter().copied().filter(|weight| *weight > 0.0).sum(); + if amount == 0 || total <= 0.0 { + return vec![0; weights.len()]; + } + let mut shares = weights + .iter() + .map(|weight| { + if *weight > 0.0 { + ((amount as f64 * *weight) / total).floor() as i64 + } else { + 0 + } + }) + .collect::>(); + let mut remaining = amount - shares.iter().sum::(); + while remaining > 0 { + for (share, weight) in shares.iter_mut().zip(weights.iter()) { + if remaining == 0 { + break; + } + if *weight > 0.0 { + *share += 1; + remaining -= 1; + } + } + } + shares +} + +fn clamp_flex_main(size: i64, min_main: i64, max_main: Option) -> i64 { + let size = size.max(0); + let min_main = min_main.max(0); + let upper = max_main.unwrap_or(999_999_999).max(0); + min_main.max(upper.min(size)) +} + +fn flex_size_line(items: &[NativeFlexItem], main_limit: i64, main_gap: i64) -> Vec { + let count = items.len(); + let gap_total = main_gap * (count.saturating_sub(1) as i64); + let available = main_limit - gap_total; + let mut targets = items + .iter() + .map(|item| item.hypothetical) + .collect::>(); + let hypothetical_total = items.iter().map(|item| item.hypothetical).sum::(); + let grow = hypothetical_total < available; + let mut frozen = vec![false; count]; + + for (index, item) in items.iter().enumerate() { + let factor = if grow { item.grow } else { item.shrink }; + targets[index] = item.base; + if factor <= 0.0 + || (grow && item.base > item.hypothetical) + || (!grow && item.base < item.hypothetical) + { + targets[index] = item.hypothetical; + frozen[index] = true; + } + } + + let initial_free = available + - items + .iter() + .enumerate() + .map(|(index, item)| { + if frozen[index] { + targets[index] + } else { + item.base + } + }) + .sum::(); + loop { + let free = available + - items + .iter() + .enumerate() + .map(|(index, item)| { + if frozen[index] { + targets[index] + } else { + item.base + } + }) + .sum::(); + let active = (0..count) + .filter(|index| !frozen[*index]) + .collect::>(); + let weights = active + .iter() + .map(|index| { + let item = items[*index]; + if grow { + item.grow + } else { + item.base as f64 * item.shrink + } + }) + .collect::>(); + let weight_total: f64 = weights.iter().copied().sum(); + let factor_total: f64 = active + .iter() + .map(|index| { + let item = items[*index]; + if grow { + item.grow + } else { + item.shrink + } + }) + .sum(); + let effective_free = if factor_total > 0.0 && factor_total < 1.0 { + let partial = initial_free as f64 * factor_total; + if partial.abs() < (free as f64).abs() { + partial + } else { + free as f64 + } + } else { + free as f64 + }; + if active.is_empty() + || weight_total <= 0.0 + || (grow && effective_free < 0.0) + || (!grow && effective_free > 0.0) + { + break; + } + + let deltas = flex_distribute(effective_free.abs().floor() as i64, &weights); + let mut min_violations = Vec::new(); + let mut max_violations = Vec::new(); + let mut total_violation = 0i64; + for (active_offset, index) in active.iter().enumerate() { + let item = items[*index]; + let candidate = if grow { + item.base + deltas[active_offset] + } else { + item.base - deltas[active_offset] + }; + let clamped = clamp_flex_main( + candidate, + item.min_main, + item.max_known.then_some(item.max_main), + ); + let adjustment = clamped - candidate; + targets[*index] = clamped; + if adjustment > 0 { + min_violations.push(*index); + } else if adjustment < 0 { + max_violations.push(*index); + } + total_violation += adjustment; + } + if total_violation == 0 { + for index in active { + frozen[index] = true; + } + } else if total_violation > 0 { + let indices = if min_violations.is_empty() { + active + } else { + min_violations + }; + for index in indices { + frozen[index] = true; + } + } else { + let indices = if max_violations.is_empty() { + active + } else { + max_violations + }; + for index in indices { + frozen[index] = true; + } + } + } + targets +} + +/// Calculate all flex line target sizes without touching Ebox state. +/// +/// `line_offsets` contains `line_count + 1` offsets into ITEMS. The output +/// is a little-endian i64 stream whose values follow the input line order. +#[no_mangle] +pub unsafe extern "C" fn ebox_native_flex_size_lines( + items: *const NativeFlexItem, + item_count: usize, + line_offsets: *const usize, + line_count: usize, + main_limit: i64, + main_gap: i64, + output: *mut NativeBytes, +) -> bool { + if output.is_null() + || (item_count > 0 && items.is_null()) + || (line_count > 0 && line_offsets.is_null()) + || item_count > MAX_FLEX_ITEMS + || line_count > MAX_FLEX_LINES + || main_limit < -MAX_LAYOUT_DIMENSION + || main_limit > MAX_LAYOUT_DIMENSION + || main_gap < 0 + || main_gap > MAX_LAYOUT_DIMENSION + { + set_error( + output, + "Native flex geometry input is outside the bounded contract", + ); + return false; + } + let items = slice::from_raw_parts(items, item_count); + let offsets = slice::from_raw_parts(line_offsets, line_count.saturating_add(1)); + if offsets.first().copied().unwrap_or(0) != 0 + || offsets.last().copied().unwrap_or(0) != item_count + || offsets.windows(2).any(|window| window[0] > window[1]) + { + set_error(output, "Native flex geometry line offsets are invalid"); + return false; + } + if items.iter().any(|item| { + !item.grow.is_finite() || !item.shrink.is_finite() || item.grow < 0.0 || item.shrink < 0.0 + }) { + set_error(output, "Native flex geometry factors are invalid"); + return false; + } + let mut bytes = Vec::with_capacity(item_count.saturating_mul(8)); + for window in offsets.windows(2).take(line_count) { + for target in flex_size_line(&items[window[0]..window[1]], main_limit, main_gap) { + bytes.extend_from_slice(&target.to_le_bytes()); + } + } + unsafe { + *output = NativeBytes::from_vec(bytes); + } + true +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ControlBatch { diff --git a/scripts/ebox-performance-evaluator.el b/scripts/ebox-performance-evaluator.el new file mode 100644 index 0000000..4e3ed38 --- /dev/null +++ b/scripts/ebox-performance-evaluator.el @@ -0,0 +1,584 @@ +;;; ebox-performance-evaluator.el --- Stage performance evaluator -*- lexical-binding: t; -*- + +;;; Commentary: +;; Batch entry point for the repository-local performance contract. +;; +;; emacs -Q -L . -L ../new-architecture/ecss -L ../tp \ +;; -l scripts/ebox-performance-evaluator.el \ +;; -f ebox-performance-evaluator-batch + +;;; Code: + +(require 'cl-lib) +(require 'ert) + +(defconst ebox-performance-evaluator--root + (expand-file-name ".." + (file-name-directory (or load-file-name buffer-file-name))) + "Repository root used by the performance evaluator.") + +(add-to-list 'load-path ebox-performance-evaluator--root) + +(require 'ebox) +(require 'ebox-selector) +(require 'tp-surface) + +(defconst ebox-performance-evaluator--stage-specs + '((candidate-preparation + ebox-tree-copy-node-structure + ebox-tree-validate-declarative-root + ebox-surface--clear-runtime-attachments + ebox-surface--candidate-root + ebox-surface--reconcile-candidate + ebox-incremental--prepare-declarative-root + ebox-incremental--prepare-logical-candidate + ebox-incremental--prepare-declarative-runtime + ebox-incremental--prepare-viewport-commit + ebox-tree-reconcile-runtime) + (style-cascade + ebox-tree-subject-index + ebox-style-compute-subject + ecss-compute-style + ebox-style-apply-computed) + (node-projection + ebox-surface--projection-start + ebox-surface--ensure-node-tree + ebox--runtime-index) + (layout-fragment-ownership + ebox--render-layout + ebox-surface--render-candidate + ebox-surface--owned-ranges + ebox-surface--surface-plan + ebox-incremental--layout-owner-plan) + (tp-object-preparation + tp-object-ensure + tp-object-retain + tp-bind) + (publication + tp-surface-materialize-string + tp-surface-mount + tp-surface-update + tp-surface-update-scoped + ebox-surface--publish-runtime-state)) + "Functions grouped by computation or publication stage.") + +(defvar ebox-performance-evaluator--active nil + "Non-nil while a measured evaluator action is running.") + +(defvar ebox-performance-evaluator--stack nil + "Advice frames used to subtract nested stage timings.") + +(defvar ebox-performance-evaluator--metrics nil + "Hash table containing per-function inclusive and exclusive timings.") + +(defun ebox-performance-evaluator--hash-table-p (symbol) + "Return non-nil when SYMBOL is bound to a hash table." + (and (boundp symbol) (hash-table-p (symbol-value symbol)))) + +(defun ebox-performance-evaluator--cancel-timers (symbol) + "Cancel and clear timers stored in hash table SYMBOL." + (when (ebox-performance-evaluator--hash-table-p symbol) + (maphash (lambda (_key timer) + (when (timerp timer) + (cancel-timer timer))) + (symbol-value symbol)) + (clrhash (symbol-value symbol)))) + +(defun ebox-performance-evaluator--reset-runtime-state () + "Reset global Ebox runtime tables used by one evaluator scenario." + (setq ebox--region-id-counter 0 + ebox--runtime-node-id-counter 0 + ebox--render-region-id nil + ebox--deferred-render-gc-state nil) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer)) + (setq ebox--deferred-render-gc-timer nil) + (dolist (symbol '(ebox--region-box-table + ebox--buffer-render-state-table + ebox--scroll-global-state + ebox--smooth-scroll-state-table + ebox--render-root-cache-table-table + ebox--render-cache-entry-side-effects-table + ebox--rendered-uniform-width-table + ebox--flex-sized-render-observation-table + ebox--runtime-prewarm-jobs + ebox--flex-content-min-width-table)) + (when (ebox-performance-evaluator--hash-table-p symbol) + (clrhash (symbol-value symbol)))) + (dolist (symbol '(ebox--scroll-idle-prefetch-timers + ebox--runtime-prewarm-timers + ebox--reflow-cache-prewarm-timers)) + (ebox-performance-evaluator--cancel-timers symbol)) + (when (fboundp 'ebox-cache-reset-default-registry) + (ebox-cache-reset-default-registry))) + +(defun ebox-performance-evaluator--reset-counters () + "Reset ephemeral counters without changing the stylesheet." + (setq ebox--region-id-counter 0 + ebox--runtime-node-id-counter 0 + ebox--render-region-id nil)) + +(defun ebox-performance-evaluator--metric (key) + "Return or create the timing vector for KEY." + (or (gethash key ebox-performance-evaluator--metrics) + (let ((value (vector 0 0.0 0.0))) + (puthash key value ebox-performance-evaluator--metrics) + value))) + +(defun ebox-performance-evaluator--record (category function elapsed exclusive) + "Record CATEGORY FUNCTION timing values ELAPSED and EXCLUSIVE." + (let ((value (ebox-performance-evaluator--metric + (list category function)))) + (aset value 0 (1+ (aref value 0))) + (aset value 1 (+ (aref value 1) elapsed)) + (aset value 2 (+ (aref value 2) (max 0.0 exclusive))))) + +(defun ebox-performance-evaluator--around + (category function original &rest arguments) + "Measure ORIGINAL under CATEGORY and FUNCTION while preserving its result." + (if (not ebox-performance-evaluator--active) + (apply original arguments) + (let ((frame (list category function (float-time) 0.0))) + (push frame ebox-performance-evaluator--stack) + (unwind-protect + (apply original arguments) + (let* ((elapsed (- (float-time) (nth 2 frame))) + (exclusive (- elapsed (nth 3 frame)))) + (setq ebox-performance-evaluator--stack + (cdr ebox-performance-evaluator--stack)) + (when ebox-performance-evaluator--stack + (setf (nth 3 (car ebox-performance-evaluator--stack)) + (+ (nth 3 (car ebox-performance-evaluator--stack)) + elapsed))) + (ebox-performance-evaluator--record + category function elapsed exclusive)))))) + +(defun ebox-performance-evaluator--install-advice () + "Install timing advice and return the installed advice descriptors." + (let (installed) + (dolist (spec ebox-performance-evaluator--stage-specs) + (let ((category (car spec))) + (dolist (function (cdr spec)) + (when (fboundp function) + (let ((advice + (lambda (original &rest arguments) + (apply #'ebox-performance-evaluator--around + category function original arguments)))) + (advice-add function :around advice) + (push (list function advice) installed)))))) + installed)) + +(defun ebox-performance-evaluator--remove-advice (installed) + "Remove timing advice descriptors in INSTALLED." + (dolist (entry installed) + (advice-remove (nth 0 entry) (nth 1 entry)))) + +(defun ebox-performance-evaluator--category-report (metrics) + "Return category and function timing reports from METRICS." + (let ((categories (make-hash-table :test #'eq)) functions) + (maphash + (lambda (key value) + (let* ((category (car key)) + (function (cadr key)) + (category-value + (or (gethash category categories) + (let ((new (vector 0 0.0 0.0))) + (puthash category new categories) + new)))) + (dotimes (index 3) + (aset category-value index + (+ (aref category-value index) (aref value index)))) + (push (list :category category :function function + :count (aref value 0) + :inclusive (aref value 1) + :exclusive (aref value 2)) + functions))) + metrics) + (list + :categories + (sort + (let (result) + (maphash + (lambda (category value) + (push (list :category category :count (aref value 0) + :inclusive (aref value 1) + :exclusive (aref value 2)) + result)) + categories) + result) + (lambda (left right) + (string< (symbol-name (plist-get left :category)) + (symbol-name (plist-get right :category))))) + :functions + (sort functions + (lambda (left right) + (if (eq (plist-get left :category) + (plist-get right :category)) + (> (plist-get left :exclusive) + (plist-get right :exclusive)) + (string< (symbol-name (plist-get left :category)) + (symbol-name (plist-get right :category))))))))) + +(defun ebox-performance-evaluator--measure (thunk) + "Run THUNK with stage advice and return its value and timing report." + (let ((installed (ebox-performance-evaluator--install-advice))) + (unwind-protect + (let ((ebox-performance-evaluator--active t) + (ebox-performance-evaluator--stack nil) + (ebox-performance-evaluator--metrics + (make-hash-table :test #'equal)) + (start (float-time))) + (let ((value (funcall thunk))) + (list :value value + :elapsed (- (float-time) start) + :timings + (ebox-performance-evaluator--category-report + ebox-performance-evaluator--metrics)))) + (ebox-performance-evaluator--remove-advice installed)))) + +(defun ebox-performance-evaluator--assert (condition format-string &rest args) + "Signal an evaluator failure unless CONDITION is non-nil." + (unless condition + (error "Ebox performance evaluator: %s" + (apply #'format format-string args)))) + +(defun ebox-performance-evaluator--check (name condition detail) + "Return a named check with NAME, CONDITION, and DETAIL." + (list :name name :ok (and condition t) :detail detail)) + +(defun ebox-performance-evaluator--assert-timings (measurement) + "Return a non-overlap check for MEASUREMENT's inclusive/exclusive timings." + (let* ((timings (plist-get measurement :timings)) + (categories (plist-get timings :categories)) + (exclusive-total + (apply #'+ (mapcar (lambda (entry) (plist-get entry :exclusive)) + categories))) + (elapsed (plist-get measurement :elapsed))) + (ebox-performance-evaluator--check + "stage timings are non-overlapping" + (and (cl-every + (lambda (entry) + (>= (+ (plist-get entry :inclusive) 0.000001) + (plist-get entry :exclusive))) + categories) + (<= exclusive-total (+ elapsed 0.01))) + (format "elapsed=%.6fs exclusive-total=%.6fs" + elapsed exclusive-total)))) + +(defun ebox-performance-evaluator--buffer-string (buffer) + "Return BUFFER's complete propertized contents." + (with-current-buffer buffer + (save-restriction + (widen) + (buffer-substring (point-min) (point-max))))) + +(defun ebox-performance-evaluator--runtime-output (buffer) + "Return a fresh propertized render of BUFFER's current runtime root." + (let ((ebox--scroll-global-state (make-hash-table :test #'equal)) + (ebox--region-box-table (make-hash-table :test #'equal))) + (ebox--with-buffer-render-context buffer + (ebox-render (ebox--buffer-root-node buffer))))) + +(defun ebox-performance-evaluator--static-fixture (&optional styled) + "Return a deterministic nested fixture, optionally marked STYLED." + (let (rows) + (dotimes (row 5) + (let (cells) + (dotimes (column 4) + (push + (apply #'ebox-create + (append + (list :key (list 'cell row column) + :content (format "row-%d cell-%d\ndetail-%d-%d" + row column row column) + :width '(52) + :padding '(0 1) + :color "#0F172A" + :bgcolor "#F8FAFC") + (when (and (= row 2) (= column 1)) + '(:id target)) + (when styled '(:class card)))) + cells)) + (push (apply #'ebox-row (nreverse cells)) rows))) + (apply #'ebox-column (nreverse rows)))) + +(defun ebox-performance-evaluator--resize-fixture () + "Return a viewport-dependent wrapped flex fixture." + (let (items) + (dotimes (index 12) + (push + (ebox-flex-item + (ebox-create + :key (list 'resize-item index) + :content (format "resize-%02d" index) + :width '(70) + :padding '(0 1) + :color "#172554" + :bgcolor "#DBEAFE")) + items)) + (apply #'ebox-flex + :key 'resize-root + :width '(viewport) + :flex-wrap 'wrap + :column-gap '(6) + :row-gap 1 + (nreverse items)))) + +(defun ebox-performance-evaluator--pure-static () + "Measure isolated static materialization against a TP-backed reference." + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (ebox-viewport-width 240) + (ebox-viewport-height 12)) + (ebox-performance-evaluator--reset-runtime-state) + (let ((expected + (tp-surface-materialize-string + (ebox-surface-producer + (ebox-performance-evaluator--static-fixture) nil t)))) + (ebox-performance-evaluator--reset-runtime-state) + (let* ((measurement + (ebox-performance-evaluator--measure + (lambda () + (ebox-render + (ebox-performance-evaluator--static-fixture))))) + (actual (plist-get measurement :value)) + (checks + (list + (ebox-performance-evaluator--check + "isolated output matches TP-backed reference" + (equal-including-properties actual expected) + (format "characters=%d" (length actual))) + (ebox-performance-evaluator--assert-timings measurement)))) + (ebox-performance-evaluator--assert + (cl-every (lambda (check) (plist-get check :ok)) checks) + "pure static scenario failed: %S" checks) + (list :name 'pure-static :measurement measurement + :checks checks :tp-report nil))))) + +(defun ebox-performance-evaluator--stylesheet-pure () + "Measure pure projection with an active ECSS stylesheet." + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (ebox-viewport-width 240) + (ebox-viewport-height 12)) + (ebox-style-add-rule + ".card" '(:color "#F8FAFC" :bgcolor "#1E293B") :layer 'components) + (ebox-performance-evaluator--reset-runtime-state) + (let ((ebox-performance-evaluator--active nil) + (expected + (tp-surface-materialize-string + (ebox-surface-producer + (ebox-performance-evaluator--static-fixture t) nil t)))) + (ebox-performance-evaluator--reset-runtime-state) + (let* ((measurement + (ebox-performance-evaluator--measure + (lambda () + (ebox-render + (ebox-performance-evaluator--static-fixture t))))) + (actual (plist-get measurement :value)) + (style-count + (cl-loop for entry in (plist-get (plist-get measurement :timings) + :functions) + when (and (eq (plist-get entry :category) + 'style-cascade) + (eq (plist-get entry :function) + 'ecss-compute-style)) + sum (plist-get entry :count))) + (checks + (list + (ebox-performance-evaluator--check + "stylesheet output matches a second TP materialization" + (equal-including-properties actual expected) + (format "characters=%d" (length actual))) + (ebox-performance-evaluator--check + "stylesheet cascade was exercised" + (> style-count 0) + (format "ecss-compute-style calls=%d" style-count)) + (ebox-performance-evaluator--assert-timings measurement)))) + (ebox-performance-evaluator--assert + (cl-every (lambda (check) (plist-get check :ok)) checks) + "stylesheet pure scenario failed: %S" checks) + (list :name 'stylesheet-pure :measurement measurement + :checks checks :tp-report nil))))) + +(defun ebox-performance-evaluator--mounted-update () + "Measure one scoped mounted content update and validate its output." + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (ebox-viewport-width 240) + (ebox-viewport-height 12) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil) + (buffer (generate-new-buffer " *ebox-performance-update*"))) + (unwind-protect + (progn + (ebox-performance-evaluator--reset-runtime-state) + (ebox-render-to-buffer + buffer (ebox-performance-evaluator--static-fixture)) + (let* ((handle (ebox-region-resolve buffer "target")) + (before-object + (ebox-selector--region-handle-object handle)) + (measurement + (ebox-performance-evaluator--measure + (lambda () + (ebox-region-update handle :content "UPDATED\ndetail")))) + (report (ebox-buffer-update-report buffer)) + (surface (plist-get (ebox--buffer-render-state buffer) + :surface)) + (after-object + (ebox-selector--region-handle-object handle)) + (expected (ebox-performance-evaluator--runtime-output buffer)) + (actual (ebox-performance-evaluator--buffer-string buffer)) + (tp-report (tp-surface-report surface)) + (checks + (list + (ebox-performance-evaluator--check + "mounted output matches runtime render" + (equal-including-properties actual expected) + (format "characters=%d" (length actual))) + (ebox-performance-evaluator--check + "target mounted identity is retained" + (eq before-object after-object) + (format "same-object=%S" (eq before-object after-object))) + (ebox-performance-evaluator--check + "publication is scoped" + (and (not (plist-get tp-report :full-root)) + (> (or (plist-get tp-report :scope-count) 0) 0)) + (format "full-root=%S scope-count=%S" + (plist-get tp-report :full-root) + (plist-get tp-report :scope-count))) + (ebox-performance-evaluator--check + "update report confirms runtime publication" + (plist-get report :runtime-published) + (format "strategy=%S" (plist-get report :strategy))) + (ebox-performance-evaluator--assert-timings measurement)))) + (ebox-performance-evaluator--assert + (cl-every (lambda (check) (plist-get check :ok)) checks) + "mounted update scenario failed: %S" checks) + (list :name 'mounted-content-update :measurement measurement + :checks checks :tp-report tp-report))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(defun ebox-performance-evaluator--viewport-resize () + "Measure one viewport resize and validate its runtime output." + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (ebox-viewport-width 180) + (ebox-viewport-height 6) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil) + (buffer (generate-new-buffer " *ebox-performance-resize*"))) + (unwind-protect + (progn + (ebox-performance-evaluator--reset-runtime-state) + (ebox-render-to-buffer + buffer (ebox-performance-evaluator--resize-fixture)) + (let* ((old-state (ebox--buffer-render-state buffer)) + (old-root-object + (plist-get (plist-get old-state :root-node) :surface-object)) + (measurement + (ebox-performance-evaluator--measure + (lambda () + (ebox-rerender-buffer-with-context buffer 320 6)))) + (report (ebox-buffer-update-report buffer)) + (surface (plist-get (ebox--buffer-render-state buffer) + :surface)) + (expected (ebox-performance-evaluator--runtime-output buffer)) + (actual (ebox-performance-evaluator--buffer-string buffer)) + (tp-report (tp-surface-report surface)) + (new-root-object + (plist-get (plist-get (ebox--buffer-render-state buffer) + :root-node) + :surface-object)) + (checks + (list + (ebox-performance-evaluator--check + "resized output matches runtime render" + (equal-including-properties actual expected) + (format "characters=%d" (length actual))) + (ebox-performance-evaluator--check + "root mounted identity is retained" + (eq old-root-object new-root-object) + (format "same-root-object=%S" + (eq old-root-object new-root-object))) + (ebox-performance-evaluator--check + "viewport update publishes through TP" + (and (plist-get report :runtime-published) + (tp-surface-live-p surface)) + (format "strategy=%S" (plist-get report :strategy))) + (ebox-performance-evaluator--assert-timings measurement)))) + (ebox-performance-evaluator--assert + (cl-every (lambda (check) (plist-get check :ok)) checks) + "viewport resize scenario failed: %S" checks) + (list :name 'viewport-resize :measurement measurement + :checks checks :tp-report tp-report))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(defun ebox-performance-evaluator--print-report (report) + "Print REPORT in a stable human-readable form." + (let* ((measurement (plist-get report :measurement)) + (timings (plist-get measurement :timings))) + (princ (format "SCENARIO %s elapsed=%.6fs\n" + (plist-get report :name) + (plist-get measurement :elapsed))) + (dolist (category (plist-get timings :categories)) + (princ + (format "STAGE %s count=%d inclusive=%.6fs exclusive=%.6fs\n" + (plist-get category :category) + (plist-get category :count) + (plist-get category :inclusive) + (plist-get category :exclusive)))) + (dolist (function (plist-get timings :functions)) + (when (memq (plist-get function :category) + '(candidate-preparation node-projection)) + (princ + (format "DETAIL %s/%s count=%d inclusive=%.6fs exclusive=%.6fs\n" + (plist-get function :category) + (plist-get function :function) + (plist-get function :count) + (plist-get function :inclusive) + (plist-get function :exclusive))))) + (dolist (check (plist-get report :checks)) + (princ (format "%s %s -- %s\n" + (if (plist-get check :ok) "PASS" "FAIL") + (plist-get check :name) + (plist-get check :detail)))) + (when-let ((tp-report (plist-get report :tp-report))) + (princ (format "TP-REPORT %S\n" tp-report))) + (princ "\n"))) + +(defun ebox-performance-evaluator--all-passed-p (reports) + "Return non-nil when every check in REPORTS passed." + (cl-every + (lambda (report) + (cl-every (lambda (check) (plist-get check :ok)) + (plist-get report :checks))) + reports)) + +;;;###autoload +(defun ebox-performance-evaluator-report () + "Run all declared Ebox performance scenarios and print their reports." + (let ((reports (list (ebox-performance-evaluator--pure-static) + (ebox-performance-evaluator--stylesheet-pure) + (ebox-performance-evaluator--mounted-update) + (ebox-performance-evaluator--viewport-resize)))) + (dolist (report reports) + (ebox-performance-evaluator--print-report report)) + (ebox-performance-evaluator--assert + (ebox-performance-evaluator--all-passed-p reports) + "one or more scenarios failed") + (princ "PERFORMANCE-EVALUATOR PASS\n") + reports)) + +;;;###autoload +(defun ebox-performance-evaluator-batch () + "Run the performance evaluator as a batch command with an exit status." + (condition-case error-data + (progn + (ebox-performance-evaluator-report) + (kill-emacs 0)) + (error + (princ (format "PERFORMANCE-EVALUATOR FAIL: %S\n" error-data)) + (kill-emacs 1)))) + +(provide 'ebox-performance-evaluator) + +;;; ebox-performance-evaluator.el ends here diff --git a/tests/ebox-ci-contract-tests.el b/tests/ebox-ci-contract-tests.el index a493be4..4a089e2 100644 --- a/tests/ebox-ci-contract-tests.el +++ b/tests/ebox-ci-contract-tests.el @@ -20,7 +20,7 @@ "surface-tests:" "docs-contract-tests:" "ci-contract-tests:" "native-rust-tests:" - "native-build:" "diff-check:")) + "native-build:" "performance-evaluator:" "diff-check:")) (should (string-match-p (concat "^" (regexp-quote target)) source))) (should-not (string-match-p "ETAF" source)) (should-not (string-match-p "etaf" source)))) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index edf7b37..26bdf54 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -284,6 +284,19 @@ face)) (t nil))) +(defun ebox-test--effective-face-value (face key) + "Return the first anonymous FACE value declared for KEY." + (cond + ((null face) nil) + ((and (listp face) (keywordp (car face))) + (and (plist-member face key) (plist-get face key))) + ((listp face) + (cl-loop for item in face + when (and (listp item) + (keywordp (car item)) + (plist-member item key)) + return (plist-get item key))))) + (defun ebox-test--face-visual-attributes (face) "Return FACE attributes normalized by Emacs anonymous-face precedence." (let ((entries (if (and (listp face) (not (keywordp (car-safe face)))) @@ -414,6 +427,305 @@ (get-text-property 0 'face (nth 2 lines)) :background "#29364A")))) +(ert-deftest ebox-production-render-consumes-isolated-ecss-cascade () + "Ebox rules should reach the real render path through ECSS cascade." + (ebox-test--reset-runtime-state) + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule + ".card" '(:color "#F8FAFC" :bgcolor "#1E293B") + :layer 'components) + (let* ((node (ebox-create :class 'card :content "Cascade" :width '(96))) + (face (ebox-test--first-content-face (ebox-render node)))) + (should (ebox-test--face-has-key-value-p + face :foreground "#F8FAFC")) + (should (ebox-test--face-has-key-value-p + face :background "#1E293B"))))) + +(ert-deftest ebox-ecss-value-resolver-uses-tp-computed-contract () + "ECSS should resolve tagged TP values while preserving literal functions." + (let ((calls 0) + (literal (lambda () 'literal))) + (should + (= (plist-get + (ebox-style-compute + (list :font-height + (tp-computed (lambda () (cl-incf calls) 1.25)))) + :font-height) + 1.25)) + (should (= calls 1)) + (should + (eq (plist-get (ebox-style-compute (list :font-height literal)) + :font-height) + literal)))) + +(ert-deftest ebox-style-context-reads-each-ecss-snapshot-once () + "Style projection should not repeatedly copy one computed ECSS snapshot." + (let* ((style + (ebox-style-compute-subject + (ecss-subject-create :type "box") + (ebox-style-compile-declarations + '(:width (120) :padding (1 (8)) :bgcolor "#1E293B")))) + (original-values (symbol-function 'ecss-computed-style-values)) + (original-active + (symbol-function 'ecss-computed-style-active-properties)) + (original-specified + (symbol-function 'ecss-computed-style-specified-properties)) + (original-provenance + (symbol-function 'ecss-computed-style-provenance)) + (values-reads 0) + (active-reads 0) + (specified-reads 0) + (provenance-reads 0)) + (cl-letf (((symbol-function 'ecss-computed-style-values) + (lambda (computed) + (cl-incf values-reads) + (funcall original-values computed))) + ((symbol-function 'ecss-computed-style-active-properties) + (lambda (computed) + (cl-incf active-reads) + (funcall original-active computed))) + ((symbol-function 'ecss-computed-style-specified-properties) + (lambda (computed) + (cl-incf specified-reads) + (funcall original-specified computed))) + ((symbol-function 'ecss-computed-style-provenance) + (lambda (computed) + (cl-incf provenance-reads) + (funcall original-provenance computed)))) + (should (ebox-style--context-values style 'box t))) + (should (= values-reads 1)) + (should (= active-reads 1)) + (should (= specified-reads 1)) + (should (= provenance-reads 0)))) + +(ert-deftest ebox-style-apply-computed-reuses-one-ecss-snapshot () + "One computed style projection should reuse its ECSS snapshot." + (let* ((style + (ebox-style-compute-subject + (ecss-subject-create :type "flex") + (ebox-style-compile-declarations + '(:width (120) :flex-direction row :color "#F8FAFC")))) + (node (list :ebox-type 'flex :raw-props nil :props nil)) + (original-values (symbol-function 'ecss-computed-style-values)) + (original-active + (symbol-function 'ecss-computed-style-active-properties)) + (original-specified + (symbol-function 'ecss-computed-style-specified-properties)) + (values-reads 0) + (active-reads 0) + (specified-reads 0)) + (cl-letf (((symbol-function 'ecss-computed-style-values) + (lambda (computed) + (cl-incf values-reads) + (funcall original-values computed))) + ((symbol-function 'ecss-computed-style-active-properties) + (lambda (computed) + (cl-incf active-reads) + (funcall original-active computed))) + ((symbol-function 'ecss-computed-style-specified-properties) + (lambda (computed) + (cl-incf specified-reads) + (funcall original-specified computed)))) + (ebox-style-apply-computed node style)) + (should (= values-reads 1)) + (should (= active-reads 1)) + (should (= specified-reads 1)))) + +(ert-deftest ebox-cascade-inherits-color-but-honors-explicit-nil () + "A child nil color should block inheritance without inheriting background." + (ebox-test--reset-runtime-state) + (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) + (child (ebox-create :id 'child :content "Child" :color nil + :width '(96))) + (root (ebox-create :class 'panel :ebox-content-node child + :wrap-mode nil :width '(96)))) + (ebox-style-add-rule + ".panel" '(:color "#E2E8F0" :bgcolor "#0F172A") + :layer 'components) + (ebox-test--with-rendered-buffer root + (let* ((runtime-root (ebox-test--runtime-root (current-buffer))) + (runtime-child (plist-get runtime-root :ebox-content-node)) + (child-style (plist-get runtime-child :ebox-computed-style)) + (values (ecss-computed-style-values child-style)) + (active (ecss-computed-style-active-properties child-style))) + (should (memq 'ebox/color active)) + (should (null (plist-get values 'ebox/color))) + (should (null (plist-get values 'ebox/background-color))) + (should-not (memq 'ebox/background-color active)) + (goto-char (point-min)) + (search-forward "Child") + (let ((face (get-text-property (match-beginning 0) 'face))) + (should (equal (car face) '(:inherit default)))))))) + +(ert-deftest ebox-flex-layout-properties-come-from-ecss-cascade () + "An ECSS rule should determine a Flex container's actual main axis." + (ebox-test--reset-runtime-state) + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule + "flex" '(:flex-direction column :width (96)) + :layer 'layout) + (let ((rendered + (substring-no-properties + (ebox-render + (ebox-flex + (ebox-create :content "A") + (ebox-create :content "B")))))) + (should (equal (mapcar #'string-trim-right + (split-string rendered "\n")) + '("A" "B")))))) + +(ert-deftest ebox-cascade-layers-inline-values-and-reset-use-one-winner () + "Production rendering should honor ECSS layers, inline values, and reset." + (ebox-test--reset-runtime-state) + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule ".card" '(:color "#94A3B8") :layer 'base) + (ebox-style-add-rule ".card" '(:color "#38BDF8") :layer 'components) + (let* ((rule-node (ebox-create :class 'card :content "Rule")) + (inline-node + (ebox-create :class 'card :content "Inline" :color "#F8FAFC"))) + (should + (equal (ebox-test--effective-face-value + (ebox-test--first-content-face (ebox-render rule-node)) + :foreground) + "#38BDF8")) + (should + (equal (ebox-test--effective-face-value + (ebox-test--first-content-face (ebox-render inline-node)) + :foreground) + "#F8FAFC")) + (ebox-style-reset-rules) + (should-not + (ebox-test--face-has-key-p + (ebox-test--first-content-face (ebox-render rule-node)) + :foreground))))) + +(ert-deftest ebox-grid-container-and-item-properties-come-from-ecss-cascade () + "Grid container and item rules should reach the production runtime tree." + (ebox-test--reset-runtime-state) + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule + "grid" '(:grid-template-columns ((40) (40)) :column-gap (4)) + :layer 'layout) + (ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout) + (ebox-test--with-rendered-buffer + (ebox-grid + (ebox-create :class 'placed :content "Placed") + (ebox-create :content "Auto")) + (let* ((root (ebox-test--runtime-root (current-buffer))) + (placed (car (plist-get root :children)))) + (should (equal (plist-get (plist-get root :props) + :grid-template-columns) + '((40) (40)))) + (should (equal (plist-get (plist-get root :props) :grid-column-gap) + '(4))) + (should (= (plist-get placed :grid-column) 2)))))) + +(ert-deftest ebox-font-cascade-inherits-overrides-and-precedes-measurement () + "Font computed values should reach child text before pixel measurement." + (ebox-test--reset-runtime-state) + (let* ((child + (ebox-create :id 'child :content "MMMM" + :font-family "Child Family")) + (root + (ebox-create :ebox-content-node child :wrap-mode nil + :font-family "Parent Family" + :font-height 1.25 :font-weight 'bold)) + measured-face) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (string) + (when (string-match-p "MMMM" (substring-no-properties string)) + (setq measured-face (get-text-property 0 'face string))) + 40))) + (ebox-test--with-rendered-buffer root + (let* ((runtime-root (ebox-test--runtime-root (current-buffer))) + (runtime-child (plist-get runtime-root :ebox-content-node)) + (style (plist-get runtime-child :ebox-computed-style)) + (values (ecss-computed-style-values style)) + (face (ebox-test--first-content-face (buffer-string)))) + (should (equal (plist-get values 'ebox/font-family) "Child Family")) + (should (= (plist-get values 'ebox/font-height) 1.25)) + (should (eq (plist-get values 'ebox/font-weight) 'bold)) + (should + (equal (ebox-test--effective-face-value face :family) + "Child Family")) + (should (= (ebox-test--effective-face-value face :height) 1.25)) + (should (eq (ebox-test--effective-face-value face :weight) 'bold))))) + (should measured-face) + (should + (equal (ebox-test--effective-face-value measured-face :family) + "Child Family")) + (should (= (ebox-test--effective-face-value measured-face :height) 1.25)))) + +(ert-deftest ebox-column-background-owns-blank-lines-with-child-override () + "A column background should fill its blank lines without hiding child paint." + (ebox-test--reset-runtime-state) + (let* ((parent "#0F172A") + (child "#1D4ED8") + (rendered + (ebox-render + (ebox-build + `(column :width (120) :height 5 :padding-block 1 + :bgcolor ,parent + (box :width (120) :height 1 :margin-bottom 1 + :bgcolor ,child "Child"))))) + (lines (ebox-string-lines rendered))) + (should (= (length lines) 5)) + (dolist (line lines) + (should + (ebox-test--face-has-key-value-p + (get-text-property 0 'face line) :background parent))) + (let ((child-line + (cl-find-if + (lambda (line) + (string-match-p "Child" (substring-no-properties line))) + lines)) + (blank-line + (cl-find-if + (lambda (line) + (and (string-blank-p (substring-no-properties line)) + (equal (ebox-test--effective-face-value + (get-text-property 0 'face line) :background) + parent))) + lines))) + (should child-line) + (should blank-line) + (should + (equal (ebox-test--effective-face-value + (get-text-property 0 'face child-line) :background) + child))))) + +(ert-deftest ebox-paint-only-update-skips-measurement-flex-and-grid () + "A pure paint change should not recompute spatial layout." + (ebox-test--reset-runtime-state) + (let* ((target + (ebox-create :id 'target :content "Paint" :width '(60) + :color "#E2E8F0" :bgcolor "#1E293B")) + (root + (ebox-flex + :width '(160) + (ebox-grid :grid-template-columns '((60)) target)))) + (ebox-test--with-rendered-buffer root + (let ((handle (ebox-region-resolve (current-buffer) 'target))) + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (&rest _) + (error "Paint update measured text"))) + ((symbol-function 'ebox--render-flex) + (lambda (&rest _) + (error "Paint update recomputed Flex"))) + ((symbol-function 'ebox--render-grid) + (lambda (&rest _) + (error "Paint update recomputed Grid")))) + (ebox-region-update handle :bgcolor "#334155")) + (goto-char (point-min)) + (search-forward "Paint") + (should + (equal (ebox-test--effective-face-value + (get-text-property (match-beginning 0) 'face) + :background) + "#334155")) + (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) + 'paint-patch)))))) + (ert-deftest ebox-region-ids-assigns-stable-ids-before-render () (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "L" :width 40)) @@ -3573,6 +3885,25 @@ (equal (ebox--render-cache-node-signature left) (ebox--render-cache-node-signature right))))) +(ert-deftest ebox-render-cache-probe-reuses-first-render-output () + "The first cache probe should retain the identity used by its output." + (ebox-test--reset-runtime-state) + (let* ((node (ebox-create :content "Stable" :width 80)) + (ebox--surface-materialization-active t) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--box-content-render-cache (make-hash-table :test 'eq)) + (ebox--region-box-table (make-hash-table :test 'equal)) + (ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq)) + (first (ebox--render-with-cache node)) + (probe (ebox--render-cache-probe node))) + (should (plist-member probe :rendered)) + (should (equal-including-properties first + (plist-get probe :rendered))))) + (ert-deftest ebox-render-transaction-snapshots-display-signature () "One render transaction should sample its display signature once." (ebox-test--reset-runtime-state) @@ -3932,6 +4263,88 @@ (should (= fast-path-calls 1)) (should (equal-including-properties fast generic)))) +(ert-deftest ebox-flat-preformatted-formatted-string-fast-path-is-exact () + "One-pass formatted strings should preserve generic chrome and ownership." + (ebox-test--reset-runtime-state) + (let* ((box + (ebox-create + :content "Alpha beta gamma delta" :width '(88) :wrap-mode 'word + :text-align 'center :region-id 'formatted-wrapper + :padding-left-pixel 4 :padding-right-pixel 6 + :border-left-pixel 1 :border-left-color "#AA0000" + :border-right-pixel 2 :border-right-color "#00AA00" + :color "#111111" :bgcolor "#EEEEEE")) + (fast-path-calls 0) + (original + (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)) + generic fast) + (let ((ebox--flat-preformatted-box-fast-path-disabled t)) + (setq generic (ebox-render box))) + (clrhash ebox--region-box-table) + (clrhash ebox--scroll-global-state) + (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) + (lambda (&rest arguments) + (cl-incf fast-path-calls) + (apply original arguments)))) + (setq fast (ebox-render box))) + (should (= fast-path-calls 1)) + (should (equal-including-properties fast generic)))) + +(ert-deftest ebox-flat-preformatted-string-proves-width-before-reuse () + "Formatted string reuse must not publish an unproven uniform width." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-create :content "This content must wrap" :width '(5) + :wrap-mode nil :region-id 'overflowing-child)) + (parent (ebox-create :width '(5) :wrap-mode 'word + :region-id 'wrapping-parent + :ebox-content-node child)) + generic fast) + (let ((ebox--flat-preformatted-box-fast-path-disabled t)) + (setq generic (ebox-render parent))) + (clrhash ebox--rendered-uniform-width-table) + (clrhash ebox--region-box-table) + (let ((ebox--flat-preformatted-box-fast-path-disabled nil)) + (setq fast (ebox-render parent))) + (should (equal-including-properties fast generic)) + (should (= (ebox-string-height fast) 5)))) + +(ert-deftest ebox-flat-preformatted-fast-path-keeps-intrinsic-scroll-state () + "Intrinsic flat rendering must not clear the published scroll state." + (ebox-test--reset-runtime-state) + (let* ((box (ebox-create :content "" :width '(40) :wrap-mode nil + :region-id 'intrinsic-wrapper)) + (content (ebox-pixel-space 40)) + (state (list :scroll-offset 0 :sentinel t)) + region-id) + (plist-put box :content content) + (plist-put box :ebox-content-width-exact-p t) + (setq region-id (ebox--ensure-region-id box)) + (puthash region-id state ebox--scroll-global-state) + (let ((ebox--intrinsic-layout-measurement t)) + (ebox-render box)) + (should (eq (gethash region-id ebox--scroll-global-state) state)))) + +(ert-deftest ebox-transparent-preformatted-fast-path-keeps-intrinsic-scroll-state () + "Intrinsic transparent rendering must not clear the published scroll state." + (ebox-test--reset-runtime-state) + (let* ((child (ebox-create :content "" :width '(40) :wrap-mode nil + :region-id 'transparent-child)) + (parent (ebox-create :content "" :width '(40) :wrap-mode nil + :region-id 'transparent-parent + :ebox-content-node child)) + (content (ebox-pixel-space 40)) + (state (list :scroll-offset 0 :sentinel t)) + region-id) + (plist-put child :content content) + (plist-put child :ebox-content-width-exact-p t) + (ebox-render parent) + (clrhash ebox--region-box-table) + (setq region-id (ebox--ensure-region-id parent)) + (puthash region-id state ebox--scroll-global-state) + (let ((ebox--intrinsic-layout-measurement t)) + (ebox-render parent)) + (should (eq (gethash region-id ebox--scroll-global-state) state)))) + (ert-deftest ebox-flat-preformatted-fixed-hidden-box-fast-path-is-exact () "Exact fixed-height hidden wrappers should preserve generic output." (ebox-test--reset-runtime-state) @@ -4439,6 +4852,45 @@ (should (ebox-test--face-has-key-value-p bottom-face :underline '(:position t :color "blue"))))))) +(ert-deftest ebox-paint-projection-keeps-old-fragment-metadata-immutable () + "Paint projection should not mutate the previous state's fragment metadata." + (ebox-test--reset-runtime-state) + (let* ((layout (ebox-build + '(flex :flex-flow (row wrap) :column-gap (12) + :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" + (column + (box :id left :content "Left" :width (120) + :padding (0 (8)) :border "#D97757" + :border-bottom-p nil) + (box :content "Body" :width (120) + :padding (0 (8)) :border "#D97757" + :border-top-p nil)) + (column + (box :content "Right" :width (120) + :padding (0 (8)) :border "#5E7F6A" + :border-bottom-p nil) + (box :content "Body" :width (120) + :padding (0 (8)) :border "#5E7F6A" + :border-top-p nil))))) + (left-id (plist-get + (car (ebox-selector-query-all layout "#left")) + :region-id))) + (ebox-test--with-rendered-buffer layout + (let* ((old-state (ebox--buffer-render-state (current-buffer))) + (old-fragments (plist-get old-state :surface-fragments)) + (old-metadata (copy-tree old-fragments))) + (save-restriction + (goto-char (1+ (point-min))) + (narrow-to-region (point) (point-max)) + (ebox-test--region-update left-id + :border-bottom-p t + :border-bottom-color "blue")) + (should (equal old-metadata + (plist-get old-state :surface-fragments))) + (should (cl-every (lambda (fragment) + (not (memq :text fragment))) + old-fragments)))))) + (ert-deftest ebox-horizontal-border-removal-preserves-other-border-colors () "Removing top/bottom border surfaces must not corrupt other border faces." (ebox-test--reset-runtime-state) @@ -6757,21 +7209,32 @@ (ebox-render box) "#abcdef" "#123456"))) (ert-deftest ebox-region-update-refreshes-visible-overflow-foreground () - "Color updates should refresh visible overflow without owning its suffix." + "Color updates should add, replace, and remove visible overflow paint." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :width '(40) :height 1 :overflow 'visible - :color "#abcdef" :bgcolor "#123456" :padding-inline '(6) :margin-inline '(4))) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box + (ebox-test--region-update region-id :color "#abcdef") + (ebox-test--assert-visible-overflow-paint + (buffer-string) "#abcdef" "#123456") (ebox-test--region-update region-id :color "#fedcba") (ebox-test--assert-visible-overflow-paint - (buffer-string) "#fedcba" "#123456")))) + (buffer-string) "#fedcba" "#123456") + (ebox-test--region-update region-id :color nil) + (cl-loop for line in (ebox-string-lines (buffer-string)) + for glyph in '(?A ?B ?C) + for glyph-pos = (cl-position glyph line) + do (should (integerp glyph-pos)) + do (should-not + (ebox-test--face-has-key-p + (get-text-property glyph-pos 'face line) + :foreground)))))) (ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned () "Owner rerenders should not restore box ownership to visible overflow." @@ -12821,12 +13284,15 @@ (box-before (copy-tree box)) (text-before (buffer-string))) (ebox-test--set-buffer-update-report buffer report-before) - (cl-letf (((symbol-function 'ebox-surface--render-candidate) - (lambda (&rest _) - (error "Injected rerender failure")))) + (let ((failure (lambda (&rest _) + (error "Injected rerender failure")))) + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + failure) + ((symbol-function 'ebox-surface--render-candidate-node) + failure)) (should-error (ebox-test--region-update region-id :content "after") - :type 'error)) + :type 'error))) (should (equal box box-before)) (should (equal-including-properties (buffer-string) text-before)) (should (eq (ebox-buffer-update-report buffer) report-before)))))) diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el index 1013e23..06daceb 100644 --- a/tests/ebox-docs-contract-tests.el +++ b/tests/ebox-docs-contract-tests.el @@ -31,14 +31,16 @@ "native/Cargo.toml" "native/Cargo.lock" "native/build.rs" "native/vendor/emacs-30/emacs-module.h" "native/src/lib.rs" "native/src/layout.rs" "native/c/ebox_module.c" - "scripts/ebox-package-lint.el" "scripts/ebox-visual-check.el") + "scripts/ebox-package-lint.el" "scripts/ebox-visual-check.el" + "scripts/ebox-performance-evaluator.el") "Active Ebox files that the maintainer map must cover.") (defconst ebox-docs-test--targets '("check" "ci" "load" "compile" "core-tests" "grid-tests" "ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests" "selector-tests" "dsl-tests" "flex-tests" - "docs-contract-tests" "ci-contract-tests" "visual-check" + "docs-contract-tests" "ci-contract-tests" "performance-evaluator" + "visual-check" "native-rust-tests" "native-build" "package-lint" "diff-check") "Make targets that must be documented and executable.") diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index 89662b1..4fab863 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -85,6 +85,22 @@ (setq pos (1+ pos))) values)) +(ert-deftest ebox-flex-batch-line-sizing-preserves-fallback-semantics () + "Batch line sizing should preserve the scalar Flexbox algorithm." + (let* ((line + (list + (list :base 80 :hypothetical 80 :min-main 0 :max-main nil + :props '(:flex-grow 1 :flex-shrink 1)) + (list :base 80 :hypothetical 80 :min-main 0 :max-main nil + :props '(:flex-grow 2 :flex-shrink 1)))) + (expected + (mapcar (lambda (item) (plist-get item :target)) + (ebox--flex-size-line line 200 10))) + (actual + (mapcar (lambda (item) (plist-get item :target)) + (car (ebox--flex-size-lines (list line) 200 10))))) + (should (equal actual expected)))) + (ert-deftest ebox-flex-module-preserves-item-participation () "Extracted flex code should preserve item participation semantics." (let* ((node (ebox-build @@ -96,6 +112,37 @@ (should (string-match-p "Grow" rendered)) (should (string-match-p "Fixed" rendered)))) +(ert-deftest ebox-flex-item-clears-disappeared-ecss-props-on-region-update () + "A region update should clear removed rule props but retain inline item props." + (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) + (layout + (ebox-flex + (ebox-flex-item + (ebox-create :id "rule" :class 'grow :content "Rule" :width '(40))) + (ebox-flex-item + (ebox-create :id "inline" :content "Inline" :width '(40)) + :flex-grow 2))) + (buffer (generate-new-buffer " *ebox-flex-style-reset*"))) + (unwind-protect + (progn + (ebox-style-add-rule ".grow" '(:flex-grow 1) :layer 'layout) + (ebox-render-to-buffer buffer layout) + (let ((items (plist-get (plist-get (ebox--buffer-render-state buffer) + :root-node) + :children))) + (should (= (plist-get (plist-get (car items) :props) :flex-grow) 1)) + (should (= (plist-get (plist-get (cadr items) :props) :flex-grow) 2))) + (ebox-style-reset-rules) + (ebox-region-update (ebox-region-resolve buffer "rule") + :content "Next") + (let ((items (plist-get (plist-get (ebox--buffer-render-state buffer) + :root-node) + :children))) + (should-not (plist-member (plist-get (car items) :props) :flex-grow)) + (should (= (plist-get (plist-get (cadr items) :props) :flex-grow) 2)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-flex-accepts-flex-props-on-direct-child-box () "Flex item participation props may live directly on child boxes." (let* ((layout (ebox-flex @@ -732,6 +779,32 @@ (should (string-match-p "blue" faces)) (should-not (string-match-p "red" faces))))) +(ert-deftest ebox-flex-measurement-miss-probes-cache-once () + "A flex measurement miss should compute one exact cache probe." + (let* ((source (ebox-create :content "Measured once" :width '(80))) + (props '(:flex-basis auto)) + (ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq)) + (signature-calls 0) + (lookup-calls 0) + (original-signature + (symbol-function 'ebox--flex-render-cache-signature)) + (original-lookup (symbol-function 'ebox--render-cache-lookup))) + (cl-letf (((symbol-function 'ebox--flex-render-cache-signature) + (lambda (node) + (cl-incf signature-calls) + (funcall original-signature node))) + ((symbol-function 'ebox--render-cache-lookup) + (lambda (cache-key signature &rest arguments) + (when (and (consp cache-key) + (eq (nth 1 cache-key) 'flex-measure)) + (cl-incf lookup-calls)) + (apply original-lookup cache-key signature arguments)))) + (ebox--flex-item-measurement source props 'row 120)) + (should (= signature-calls 1)) + (should (= lookup-calls 1)))) + (ert-deftest ebox-flex-fixed-basis-wrapped-box-skips-intrinsic-render () "Known flex metrics should not render a wrapped fixed-basis box twice." (let* ((source diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el index a43530e..c51e5d0 100644 --- a/tests/ebox-grid-tests.el +++ b/tests/ebox-grid-tests.el @@ -153,7 +153,7 @@ 4)))) (ert-deftest ebox-grid-validates-flow-and-spans () - "Invalid flow and non-positive spans should fail at render time." + "Invalid flow and non-positive spans should be rejected." (should-error (ebox-render (ebox-grid :grid-auto-flow 'diagonal (ebox-create :content "A")))) @@ -166,6 +166,18 @@ (ebox-grid (ebox-grid-item (ebox-create :content "A") :grid-column 0))))) +(ert-deftest ebox-grid-rejects-invalid-ecss-placement-before-layout () + "ECSS rules should reject invalid Grid placement before layout runs." + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (dolist (declarations + '((:grid-column-span 0) + (:grid-row-span 0) + (:grid-column 0) + (:grid-row (1 :span 0)))) + (should-error (ebox-style-add-rule ".invalid" declarations))) + (dolist (placement '(1 (1) (1 :span 2) (1 3))) + (should (ebox-style-add-rule ".valid" (list :grid-column placement)))))) + (ert-deftest ebox-grid-region-update-reflows-grid-child () "Grid children should retain region updates through buffer rendering." (let* ((left (ebox-create :content "A" :id "left" :width 20)) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index ae7715e..d253cc1 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -80,6 +80,20 @@ (buffer-string)))) (should-not (string-match-p "\\_" (regexp-quote definition)) + source))))) + (ert-deftest ebox-package-exposes-buffer-report-accessor-only () "The package surface should expose buffer-owned reports, not a global API." (require 'ebox) diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el index fee03d4..52028a0 100644 --- a/tests/ebox-selector-tests.el +++ b/tests/ebox-selector-tests.el @@ -43,25 +43,25 @@ (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered))) (ert-deftest ebox-selector-parses-type-id-class-and-attributes () - "Selectors should compile predicates into TP's structured selector AST." + "Selectors should compile predicates into ECSS structured selector AST." (should (equal (ebox-selector-parse "box.warning#summary[key=main]") - '(:and (:type box) - (:id "summary") + '(:and (:type "box") (:class "warning") - (:attr :key "main"))))) + (:id "summary") + (:attr "key" "=" "main" nil))))) (ert-deftest ebox-selector-parses-child-and-descendant-combinators () - "Selectors should compile relations into nested TP combinators." + "Selectors should compile relations into nested ECSS combinators." (should (equal (ebox-selector-parse ".panel > box .label") '(:descendant - (:child (:class "panel") (:type box)) + (:child (:class "panel") (:type "box")) (:class "label"))))) (ert-deftest ebox-selector-parses-adjacent-and-general-sibling-combinators () - "Selectors should expose TP's adjacent and general sibling relations." + "Selectors should expose ECSS adjacent and general sibling relations." (should (equal (ebox-selector-parse "#first + box ~ .later") '(:sibling - (:adjacent (:id "first") (:type box)) + (:adjacent (:id "first") (:type "box")) (:class "later"))))) (ert-deftest ebox-selector-queries-tree-by-id-class-and-type () @@ -107,16 +107,16 @@ (ebox-selector-query-all node "#first ~ .later")) '("second" "third"))))) -(ert-deftest ebox-selector-query-delegates-matching-to-tp () - "Ebox should adapt nodes while TP remains the only matcher." +(ert-deftest ebox-selector-query-delegates-matching-to-ecss () + "Ebox should adapt nodes while ECSS remains the only matcher." (ebox-selector-test--reset-runtime-state) (let ((node (ebox-build '(column (box :class card :content "A") (box :content "B")))) (calls 0) - (matcher (symbol-function 'tp-selector-match-p))) - (cl-letf (((symbol-function 'tp-selector-match-p) + (matcher (symbol-function 'ecss-selector-match-p))) + (cl-letf (((symbol-function 'ecss-selector-match-p) (lambda (selector subject) (cl-incf calls) (funcall matcher selector subject)))) @@ -131,16 +131,16 @@ :content "visible" :width 12 :render-cache opaque :selector-attributes '((role . button) (rank . 3)))) (subject (ebox-tree-node-subject node)) - (attributes (tp-subject-attributes subject))) - (should (equal (tp-subject-classes subject) '("panel" "active"))) - (should (equal (tp-subject-state subject) '("focused"))) - (should (equal (cdr (assq :role attributes)) "button")) - (should (equal (cdr (assq :rank attributes)) "3")) - (should-not (assq :content attributes)) - (should-not (assq :width attributes)) - (should-not (assq :state attributes)) - (should-not (assq :render-cache attributes)) - (should-not (assq :ebox-type attributes)))) + (attributes (ecss-subject-attributes subject))) + (should (equal (ecss-subject-classes subject) '("panel" "active"))) + (should (equal (ecss-subject-states subject) '("focused"))) + (should (equal (cdr (assoc "role" attributes)) "button")) + (should (equal (cdr (assoc "rank" attributes)) "3")) + (should-not (assoc "content" attributes)) + (should-not (assoc "width" attributes)) + (should-not (assoc "state" attributes)) + (should-not (assoc "render-cache" attributes)) + (should-not (assoc "ebox-type" attributes)))) (ert-deftest ebox-selector-queries-explicit-attributes () "Attribute queries should read only explicit selector metadata." diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index ce27356..5a46211 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -27,6 +27,22 @@ (propertize "Open" 'keymap map 'mouse-face 'highlight 'help-echo "Open this item"))) +(defun ebox-surface-test--hash-fingerprint (table) + "Return a stable content fingerprint for hash TABLE. +The fingerprint checks entries rather than only table identity, so a failed +candidate cannot hide mutations by restoring the old hash-table pointer." + (let (entries) + (when (hash-table-p table) + (maphash + (lambda (key value) + (push (list (prin1-to-string key) + (sxhash-equal value)) + entries)) + table)) + (list (length entries) + (sort entries (lambda (left right) + (string< (car left) (car right))))))) + (defun ebox-surface-test--fixtures () "Return named fresh layout builders covering active Ebox layout kinds." (list @@ -122,10 +138,115 @@ (maphash (lambda (_node-id node) (when (equal (plist-get node :key) key) - (setq object (plist-get node :surface-object)))) + (setq object (plist-get node :surface-object)))) (plist-get state :node-table)) object)) +(defun ebox-surface-test--node-by-key (state key) + "Return the runtime Ebox node for KEY in STATE." + (let (match) + (maphash + (lambda (_node-id node) + (when (equal (plist-get node :key) key) + (setq match node))) + (plist-get state :node-table)) + match)) + +(ert-deftest ebox-surface-role-ids-read-one-property-snapshot () + "Role extraction preserves the ordered Ebox role mapping." + (let ((rendered (copy-sequence "x"))) + (add-text-properties + 0 1 + '(ebox-overflow-foreground-source 9 + ebox-content-owners (20 21) + ebox-content 30 + ebox-content-owner 31 + ebox-pt 32 + ignored-property ignored) + rendered) + (should (equal + (ebox-surface--role-ids-at rendered 0) + '((overflow-foreground . 9) + (content-owner . 20) + (content-owner . 21) + (content . 30) + (content-owner . 31) + (pt . 32)))))) + +(ert-deftest ebox-surface-role-gap-does-not-mutate-next-run () + "A role gap must not destructively change the following run's roles." + (let* ((rendered + (concat (propertize "A" 'ebox-pt 1) + "gap" + (propertize "B" 'ebox-content 2 'ebox-pt 1))) + (fragments (ebox-surface--rendered-fragments rendered)) + (gap (nth 1 fragments)) + (next (nth 2 fragments))) + (should (equal (plist-get gap :role-ids) + '((pt . 1) (content . 2)))) + (should (equal (plist-get next :paint-role-ids) + '((content . 2) (pt . 1)))) + (should (equal (plist-get next :role-ids) + '((content . 2) (pt . 1)))))) + +(ert-deftest ebox-surface-face-provenance-is-string-scoped () + "Face provenance replays only for the exact rendered string identity." + (let* ((rendered (propertize "x" 'face '(:foreground "owned"))) + (registry (make-hash-table :test #'eq)) + (faces (make-hash-table :test #'eq :weakness 'key)) + (ebox--render-owned-text-values registry) + (ebox--render-output-provenance-table + (make-hash-table :test #'eq :weakness 'key))) + (puthash 'face faces registry) + (puthash (get-text-property 0 'face rendered) t faces) + (ebox--record-render-output-provenance rendered) + (let ((ebox--render-owned-text-values (make-hash-table :test #'eq))) + (ebox--replay-render-output-provenance rendered) + (should + (ebox--render-owned-text-value-p + 'face (get-text-property 0 'face rendered)))) + (let ((ebox--render-owned-text-values (make-hash-table :test #'eq))) + (ebox--replay-render-output-provenance (copy-sequence rendered)) + (should-not + (ebox--render-owned-text-value-p + 'face (get-text-property 0 'face rendered)))))) + +(ert-deftest ebox-surface-face-provenance-rejects-caller-face () + "Caller-provided face values are never promoted to Ebox-owned values." + (let* ((caller-face (list :foreground "caller")) + (source (propertize "x" 'face caller-face)) + (rendered (copy-sequence source)) + (ebox--render-owned-text-values (make-hash-table :test #'eq))) + (add-face-text-property 0 1 '(:weight bold) t rendered) + (ebox--register-render-owned-face-values source rendered) + (should-not + (ebox--render-owned-text-value-p + 'face (get-text-property 0 'face rendered))))) + +(ert-deftest ebox-surface-candidate-plan-copies-face-property-values () + "Candidate plans isolate mutable face values despite provenance hints." + (let* ((color (copy-sequence "#192233")) + (font (list :family (copy-sequence "caller-font"))) + (face (list :foreground color :font font)) + (display (list 'space :width 2)) + (rendered (copy-sequence "ab")) + (owned-values (make-hash-table :test #'eq)) + (face-values (make-hash-table :test #'eq)) + (display-values (make-hash-table :test #'eq))) + (put-text-property 0 1 'face face rendered) + (put-text-property 1 2 'display display rendered) + (puthash face t face-values) + (puthash display t display-values) + (puthash 'face face-values owned-values) + (puthash 'display display-values owned-values) + (let* ((snapshot + (ebox-surface--candidate-plan-text rendered owned-values)) + (snapshot-face (get-text-property 0 'face snapshot))) + (should-not (eq snapshot-face face)) + (should-not (eq (plist-get snapshot-face :foreground) color)) + (should-not (eq (plist-get snapshot-face :font) font)) + (should (eq (get-text-property 1 'display snapshot) display))))) + (ert-deftest ebox-surface-projects-every-layout-with-exact-equivalence () "TP projection should preserve every character and text property interval." (dolist (fixture (ebox-surface-test--fixtures)) @@ -160,18 +281,146 @@ (dolist (object captured) (should-not (tp-object-live-p object))))) +(ert-deftest ebox-surface-static-projection-skips-empty-cascade () + "Inline-only static projection should not compute per-node ECSS styles." + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (calls 0) + (original (symbol-function 'ebox-style-compute-subject))) + (cl-letf (((symbol-function 'ebox-style-compute-subject) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (let ((rendered + (ebox-surface-test--render-fresh + (cdr (assq 'box (ebox-surface-test--fixtures))) t))) + (should (= calls 0)) + (should (string-match-p "Open" (substring-no-properties rendered))))))) + +(ert-deftest ebox-surface-inline-inheritance-keeps-empty-cascade () + "Inline inherited values should still compute without stylesheet rules." + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (calls 0) + (original (symbol-function 'ebox-style-compute-subject))) + (cl-letf (((symbol-function 'ebox-style-compute-subject) + (lambda (&rest args) + (cl-incf calls) + (apply original args)))) + (ebox-surface-test--render-fresh + (lambda () + (ebox-create + :font-height 1.25 + :ebox-content-node + (ebox-create :content "Inherited"))) + t)) + (should (> calls 0)))) + +(ert-deftest ebox-box-content-cache-reuses-fixed-viewport-subtree () + "A fixed box content viewport should reuse its exact composite layout." + (let* ((ebox--render-cache-table (make-hash-table :test 'equal)) + (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--viewport-dependent-node-ids-cache (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq)) + (ebox--render-owned-text-values (make-hash-table :test 'eq)) + (ebox--region-box-table (make-hash-table :test 'equal)) + (ebox--scroll-global-state (make-hash-table :test 'equal)) + (ebox-viewport-width 240) + (ebox-viewport-height 8) + (child + (ebox-column + (ebox-create :key 'responsive-child + :content "fixed local viewport" + :width '(viewport) + :height 1))) + (wrapper (ebox-create :width '(160))) + (renders 0) + (original (symbol-function + 'ebox--render-cache-render-with-scroll-actions))) + (cl-letf (((symbol-function 'ebox--render-cache-render-with-scroll-actions) + (lambda (node) + (cl-incf renders) + (funcall original node)))) + (let ((first (ebox--render-node-as-box-content child wrapper)) + (second (ebox--render-node-as-box-content child wrapper))) + (should (= renders 1)) + (should (equal-including-properties first second)))))) + (ert-deftest ebox-surface-plan-stays-free-of-runtime-state () - "The generic surface plan should not contain markers or runtime handles." + "The surface plan should not contain markers or runtime handles." (let ((original (symbol-function 'tp-surface-result-create)) - captured) + (original-owned (symbol-function 'tp-surface-result-create-owned)) + captured client-state) (cl-letf (((symbol-function 'tp-surface-result-create) - (lambda (plan &optional client-state) - (setq captured plan) - (funcall original plan client-state)))) + (lambda (plan &optional state) + (setq captured plan + client-state state) + (funcall original plan state))) + ((symbol-function 'tp-surface-result-create-owned) + (lambda (context plan &optional state) + (setq captured plan + client-state state) + (funcall original-owned context plan state)))) (ebox-surface-test--render-fresh (cdr (assq 'box (ebox-surface-test--fixtures))) t)) (should (tp-surface-plan-p captured)) - (should (ebox-surface-test--plan-pure-p captured)))) + (should (ebox-surface-test--plan-pure-p captured)) + (should (listp client-state)) + (let* ((fragments-plan (car (tp-surface-plan-children captured))) + (text-plan (car (tp-surface-plan-children fragments-plan))) + (plan-text (tp-surface-plan-text text-plan)) + (state-fragments (plist-get client-state :surface-fragments)) + (owner-position + (cl-loop for position from 0 below (length plan-text) + when (get-text-property position + 'ebox-content-owners plan-text) + return position)) + (plan-owners (and owner-position + (get-text-property owner-position + 'ebox-content-owners plan-text)))) + (should (cl-every (lambda (fragment) + (and (memq :text-source-p fragment) + (not (memq :text fragment)) + (integerp (plist-get fragment :start)) + (integerp (plist-get fragment :end)))) + state-fragments)) + (should (integerp owner-position)) + (should (listp plan-owners)) + (setcar plan-owners 'plan-mutated) + (should (cl-every (lambda (fragment) + (not (memq :ebox-content-owners fragment))) + state-fragments))))) + +(ert-deftest ebox-surface-ancestor-tags-are-candidate-local () + "Each projection candidate receives fresh ancestor ownership tags." + (let* ((region-id 'region) + (ancestor-id 'ancestor) + (region-object (make-symbol "region-object")) + (ancestor-object (make-symbol "ancestor-object")) + (region-node-table (make-hash-table :test #'equal)) + (parent-table (make-hash-table :test #'equal)) + (node-objects (make-hash-table :test #'equal)) + (region-objects (make-hash-table :test #'equal)) + (role-ids '((content . region)))) + (puthash region-id 'region-node region-node-table) + (puthash 'region-node ancestor-id parent-table) + (puthash ancestor-id ancestor-object node-objects) + (puthash region-id region-object region-objects) + (let* ((first (ebox-surface--fragment-owners + role-ids (list :region-node-table region-node-table + :parent-table parent-table) + node-objects region-objects)) + (second (ebox-surface--fragment-owners + role-ids (list :region-node-table region-node-table + :parent-table parent-table) + node-objects region-objects)) + (first-tags + (cadr (cl-find ancestor-object first :key #'car :test #'eq))) + (second-tags + (cadr (cl-find ancestor-object second :key #'car :test #'eq)))) + (should (equal first-tags '(:ebox/descendant-output t))) + (should (equal second-tags '(:ebox/descendant-output t))) + (should-not (eq first-tags second-tags))))) (ert-deftest ebox-surface-projection-does-not-mutate-a-buffer () "Pure projection should not call any final buffer mutation primitive." @@ -288,11 +537,206 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-render-materializes-through-tp-surface () - "The public string renderer should materialize one ephemeral TP surface." +(ert-deftest ebox-surface-scoped-update-reuses-copy-on-write-candidate () + "A fixed-footprint region update should hand a path candidate to the surface." (ebox-surface-test--reset-render-state) - (let ((original (symbol-function 'tp-surface-materialize-string)) - (calls 0)) + (let ((buffer (generate-new-buffer " *ebox-surface-isolated-candidate*")) + (copy-count 0) + (validation-count 0) + (reconcile-count 0) + (runtime-index-count 0) + (original-copy (symbol-function 'ebox-tree-copy-node-structure)) + (original-validate (symbol-function 'ebox-tree-validate-declarative-root)) + (original-reconcile (symbol-function 'ebox-tree-reconcile-runtime)) + (original-runtime-index (symbol-function 'ebox--runtime-index))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-column + (ebox-create :key 'target :id 'target :content "Before" + :width '(100)) + (ebox-create :key 'sibling :content "Sibling" :width '(100)))) + (let ((handle (ebox-region-resolve buffer "target"))) + (cl-letf (((symbol-function 'ebox-tree-copy-node-structure) + (lambda (&rest args) + (cl-incf copy-count) + (apply original-copy args))) + ((symbol-function 'ebox-tree-validate-declarative-root) + (lambda (&rest args) + (cl-incf validation-count) + (apply original-validate args))) + ((symbol-function 'ebox-tree-reconcile-runtime) + (lambda (&rest args) + (cl-incf reconcile-count) + (apply original-reconcile args))) + ((symbol-function 'ebox--runtime-index) + (lambda (&rest args) + (cl-incf runtime-index-count) + (apply original-runtime-index args)))) + (ebox-region-update handle :content "After"))) + (should (= copy-count 0)) + (should (= validation-count 0)) + (should (= reconcile-count 0)) + (should (= runtime-index-count 0))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-surface-span-update-renders-only-the-owner () + "A fixed-footprint span update should render its owner, not the root tree." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-surface-local-span*")) + (rendered-node-ids nil) + (ensured-node-count 0)) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-column + (ebox-create :key 'target :id 'target :content "Before" + :width '(100)) + (ebox-create :key 'sibling :content "Sibling" :width '(100)))) + (let* ((state (ebox--buffer-render-state buffer)) + (target + (cl-loop for node being the hash-values + of (plist-get state :node-table) + when (equal (plist-get node :key) 'target) + return node)) + (target-id (plist-get target :node-id)) + (handle (ebox-region-resolve buffer "target")) + (original-render (symbol-function 'ebox--render-layout)) + (original-ensure (symbol-function + 'ebox-surface--ensure-node-tree))) + (cl-letf (((symbol-function 'ebox--render-layout) + (lambda (node) + (push (plist-get node :node-id) rendered-node-ids) + (funcall original-render node))) + ((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-region-update handle :content "After")) + (should (equal (nreverse rendered-node-ids) (list target-id))) + (should (= ensured-node-count 0)) + (let ((expected (ebox-render (ebox--buffer-root-node buffer)))) + (should + (equal-including-properties + expected + (with-current-buffer buffer (buffer-string))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-surface-partial-row-span-update-renders-only-the-owner () + "A partial row owner should patch disjoint slots without full projection." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-surface-partial-row*")) + (rendered-node-ids nil) + (ensured-node-count 0)) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-row + (ebox-create :key 'left :content "Left\ndetail" :width '(52)) + (ebox-create :key 'target :id 'target + :content "Before\ndetail" :width '(52)) + (ebox-create :key 'right :content "Right\ndetail" :width '(52)))) + (let* ((state (ebox--buffer-render-state buffer)) + (target + (cl-loop for node being the hash-values + of (plist-get state :node-table) + when (equal (plist-get node :key) 'target) + return node)) + (target-id (plist-get target :node-id)) + (snapshot + (ebox--ensure-layout-snapshot-details buffer target-id)) + (spans (plist-get snapshot :buffer-spans)) + (handle (ebox-region-resolve buffer "target")) + (original-render (symbol-function 'ebox--render-layout)) + (original-ensure + (symbol-function 'ebox-surface--ensure-node-tree))) + (with-current-buffer buffer + (should (ebox-buffer--partial-line-slots-p spans)) + (should-not (ebox--spans-contiguous-lines-p spans))) + (cl-letf (((symbol-function 'ebox--render-layout) + (lambda (node) + (push (plist-get node :node-id) rendered-node-ids) + (funcall original-render node))) + ((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-region-update handle :content "UPDATED\ndetail")) + (should (equal (nreverse rendered-node-ids) (list target-id))) + (should (= ensured-node-count 0)) + (should (eq (plist-get (ebox-buffer-update-report buffer) + :strategy) + 'span-patch)) + (let* ((surface (with-current-buffer + buffer ebox-surface--buffer-surface)) + (report (tp-surface-report surface)) + (object-count + (plist-get (tp-surface-inspect surface) :object-count))) + (should (< (plist-get report :reconciled-objects) + object-count))) + (let ((expected (ebox-render (ebox--buffer-root-node buffer)))) + (should + (equal-including-properties + expected + (with-current-buffer buffer (buffer-string))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-surface-cow-fallback-preserves-published-tree-on-failure () + "A widened path candidate must not mutate the published tree before rollback." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-surface-cow-fallback*")) + (copy-count 0) + (original-copy (symbol-function 'ebox-tree-copy-node-structure))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-column + (ebox-create :key 'target :id 'target :content "Before" + :width '(100)) + (ebox-create :key 'sibling :content "Sibling" :width '(100)))) + (let* ((state (ebox--buffer-render-state buffer)) + (contents + (with-current-buffer buffer + (buffer-substring (point-min) (point-max)))) + (handle (ebox-region-resolve buffer "target")) + (region-id (cdr (ebox-selector--region-target handle))) + (tp--surface-publication-step-function + (lambda (step _surface) + (when (eq step 'client-state) + (error "Reject widened candidate"))))) + (cl-letf (((symbol-function 'ebox-tree-copy-node-structure) + (lambda (&rest args) + (cl-incf copy-count) + (apply original-copy args)))) + (should-error + (ebox-region-update handle :content "A\nB\nC"))) + (should (> copy-count 0)) + (should (eq (ebox--buffer-render-state buffer) state)) + (should (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + contents)) + (should (equal (ebox-get + (ebox--root-region-box + (plist-get state :root-node) + region-id) + :content) + "Before")))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-render-uses-isolated-layout-for-static-content () + "Static public string rendering should avoid a retained TP object tree." + (ebox-surface-test--reset-render-state) + (let ((calls 0) + (original (symbol-function 'tp-surface-materialize-string))) (cl-letf (((symbol-function 'tp-surface-materialize-string) (lambda (producer) (cl-incf calls) @@ -300,7 +744,7 @@ (should (stringp (ebox-render (ebox-create :content "Materialized" :width '(100)))))) - (should (= calls 1)))) + (should (= calls 0)))) (ert-deftest ebox-render-is-repeatable-without-consuming-runtime-identities () "Ephemeral rendering should be exact and leave live identity counters alone." @@ -372,6 +816,456 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-viewport-reflow-reuses-retained-node-subtree () + "A safe viewport reflow should reuse the retained TP node topology." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-surface-viewport-reflow*")) + (ensured-node-count 0) + (runtime-index-count 0) + (original-ensure (symbol-function 'ebox-surface--ensure-node-tree)) + (original-runtime-index (symbol-function 'ebox--runtime-index))) + (unwind-protect + (let ((ebox-viewport-width 180) + (ebox-viewport-height 6) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (apply + #'ebox-flex + :key 'viewport-reflow-root + :width '(viewport) + :flex-wrap 'wrap + :column-gap '(6) + :row-gap 1 + (cl-loop for index below 12 + collect + (ebox-flex-item + (ebox-create + :key (list 'viewport-reflow-item index) + :content (format "item-%02d" index) + :width '(70) + :padding '(0 1) + :color "#172554" + :bgcolor "#DBEAFE"))))) + (let ((old-root-object + (plist-get (plist-get (ebox--buffer-render-state buffer) + :root-node) + :surface-object))) + (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (cl-letf (((symbol-function 'ebox--runtime-index) + (lambda (&rest args) + (cl-incf runtime-index-count) + (apply original-runtime-index args)))) + (ebox-rerender-buffer-with-context buffer 320 6))) + (let* ((state (ebox--buffer-render-state buffer)) + (surface (plist-get state :surface)) + (tp-report (tp-surface-report surface)) + (expected + (let ((ebox-viewport-width 320) + (ebox-viewport-height 6)) + (ebox-render (plist-get state :root-node)))) + (actual + (with-current-buffer buffer + (buffer-substring (point-min) (point-max)))) + (object-count + (plist-get (tp-surface-inspect surface) :object-count))) + (should (= ensured-node-count 0)) + ;; The candidate owns a copied Ebox root, so its node indexes + ;; must be rebuilt even though TP's retained object topology is + ;; reused. This is the COW boundary that keeps rollback pure. + (should (= runtime-index-count 1)) + (should (<= (plist-get tp-report :reconciled-objects) 4)) + (should (< (plist-get tp-report :reconciled-objects) + object-count)) + (should (eq old-root-object + (plist-get (plist-get state :root-node) + :surface-object))) + (should (equal-including-properties actual expected))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-reflow-retains-final-sized-flex-child-fragments () + "Viewport reflow should reuse final-sized Flex child fragments exactly." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-flex-fragment-retention*")) + (stable-renders 0) + (growing-renders 0) + stable-source growing-source) + (unwind-protect + (let ((ebox-viewport-width 300) + (ebox-viewport-height 6) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (setq stable-source + (let ((node + (ebox-column + (ebox-create :key 'stable-leaf + :content "zero\none\ntwo" + :width '(100) + :height 1 + :overflow 'hidden + :surface-properties + '(help-echo "stable"))))) + (plist-put node :key 'stable-source) + node)) + (setq growing-source + (let ((node + (ebox-column + (ebox-create :key 'growing-leaf + :content "growing" + :width '(viewport) + :height 1 + :color "#0F172A" + :bgcolor "#DBEAFE")))) + (plist-put node :key 'growing-source) + node)) + (let ((original-render (symbol-function 'ebox-render))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (node) + (cond + ((equal (plist-get node :key) 'stable-source) + (cl-incf stable-renders)) + ((equal (plist-get node :key) 'growing-source) + (cl-incf growing-renders))) + (funcall original-render node)))) + (let* ((layout + (ebox-flex + :key 'fragment-root + :width '(viewport) + :height 2 + :flex-wrap 'nowrap + (ebox-flex-item stable-source + :flex-grow 0 :flex-basis '(120)) + (ebox-flex-item growing-source + :flex-grow 1 :flex-basis '(80)))) + (items (plist-get layout :children))) + (setq stable-source + (plist-get (car items) :node) + growing-source + (plist-get (cadr items) :node)) + (ebox-render-to-buffer buffer layout)) + (let* ((old-state (ebox--buffer-render-state buffer)) + (old-root-object + (plist-get (plist-get old-state :root-node) + :surface-object)) + (old-stable-object + (ebox-surface-test--object-by-key old-state + 'stable-source)) + (initial-stable-renders stable-renders) + (initial-growing-renders growing-renders) + report state expected actual + candidate-stable-renders candidate-growing-renders) + ;; Exercise the fragment boundary independently of the + ;; broader render cache; the published fragment table remains + ;; the only retained final-sized output for this reflow. + (clrhash (plist-get old-state :render-cache)) + (setq ebox-fragment-flex-retention-hit-count 0 + ebox-fragment-flex-retention-rerender-count 0) + (setq report + (ebox-rerender-buffer-with-context buffer 360 6)) + (setq state (ebox--buffer-render-state buffer) + candidate-stable-renders stable-renders + candidate-growing-renders growing-renders + expected + (let ((ebox-viewport-width 360) + (ebox-viewport-height 6)) + (ebox-render (plist-get state :root-node))) + actual + (with-current-buffer buffer + (buffer-substring (point-min) (point-max)))) + (should (eq (plist-get report :projection-kind) + 'viewport-reflow)) + (should (eq old-root-object + (plist-get (plist-get state :root-node) + :surface-object))) + (should (eq old-stable-object + (ebox-surface-test--object-by-key + state 'stable-source))) + (should (equal-including-properties actual expected)) + (should (= candidate-stable-renders initial-stable-renders)) + (should (> candidate-growing-renders initial-growing-renders)) + (should (> ebox-fragment-flex-retention-hit-count 0)) + (should (> ebox-fragment-flex-retention-rerender-count 0)) + (with-current-buffer buffer + (goto-char (point-min)) + (should (text-property-search-forward + 'help-echo "stable" t))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-flex-fragment-retention-rejects-scroll-installations () + "Fragment retention must reject cached scroll state installations." + (let ((ebox--layout-fragments-table (make-hash-table :test 'equal)) + (ebox--layout-fragments-reuse-p t) + (ebox-fragment-flex-retention-store-count 0) + (set-effects + '(:scroll-actions ((set 9 (:scroll-offset 0 :box old-box))))) + (clear-effects '(:scroll-actions ((clear 9)))) + (pure-effects '(:scroll-actions nil)) + (entry '(:rendered "stable" :main 20 :cross 1))) + (should-not + (ebox-fragment-flex-retained-side-effects-reusable-p set-effects)) + (should-not + (ebox-fragment-flex-retained-side-effects-reusable-p clear-effects)) + (should + (ebox-fragment-flex-retained-side-effects-reusable-p pure-effects)) + (should-not + (ebox-fragment-flex-retained-side-effects-reusable-p nil)) + (ebox-fragment-flex-retention-store 'stable entry set-effects) + (should (= (hash-table-count ebox--layout-fragments-table) 0)) + (ebox-fragment-flex-retention-store 'stable entry clear-effects) + (should (= (hash-table-count ebox--layout-fragments-table) 0)) + (ebox-fragment-flex-retention-store 'stable entry pure-effects) + (should (= (hash-table-count ebox--layout-fragments-table) 1)) + (should (= ebox-fragment-flex-retention-store-count 1)) + (should (ebox-fragment-flex-retention-lookup 'stable)))) + +(ert-deftest ebox-flex-fragment-key-normalizes-only-height-independent-contexts () + "Retained Flex keys ignore height only after an explicit dependency proof." + (let ((ebox--layout-fragments-table (make-hash-table :test 'equal)) + (node (ebox-create :content "stable" :width '(80)))) + (cl-labels + ((key-at (height dependency) + (let ((ebox-viewport-height height)) + (ebox-fragment-flex-allocation-key + node 'column 1 80 'stretch 80 + (list :viewport-height-dependent dependency))))) + (should (equal (key-at nil nil) (key-at 36 nil))) + (should-not (equal (key-at nil t) (key-at 36 t))) + (should-not (equal (key-at nil :unknown) (key-at 36 :unknown)))))) + +(ert-deftest ebox-flex-fragment-retention-evicts-one-entry-at-capacity () + "Fragment retention capacity must evict one old entry, not clear the table." + (let ((ebox--layout-fragments-table (make-hash-table :test 'equal)) + (ebox-fragment-flex-retention-max-entries 2) + (effects '(:scroll-actions nil)) + (entry '(:rendered "stable" :main 20 :cross 1))) + (ebox-fragment-flex-retention-store 'one entry effects) + (ebox-fragment-flex-retention-store 'two entry effects) + (ebox-fragment-flex-retention-store 'three entry effects) + (should (= (hash-table-count ebox--layout-fragments-table) 2)))) + +(ert-deftest ebox-viewport-reflow-falls-back-for-active-stylesheet () + "A stylesheet-dependent viewport change must rebuild styled node objects." + (ebox-surface-test--reset-render-state) + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (buffer (generate-new-buffer " *ebox-viewport-cascade*")) + (ensured-node-count 0) + (original-ensure (symbol-function 'ebox-surface--ensure-node-tree))) + (unwind-protect + (let ((ebox-viewport-width 160) + (ebox-viewport-height 6) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-style-add-rule ".viewport-cascade" + '(:color "#1D4ED8") + :layer 'base) + (ebox-render-to-buffer + buffer + (ebox-create :class 'viewport-cascade + :content "Cascade" + :width '(viewport))) + (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-rerender-buffer-with-context buffer 240 6)) + (let ((report (ebox-buffer-update-report buffer))) + (should (> ensured-node-count 0)) + (should-not (plist-get report :projection-kind)) + (should (plist-get report :runtime-published)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-reflow-falls-back-for-inline-inheritance () + "An inherited inline value must keep the full styled projection path." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-viewport-inheritance*")) + (ensured-node-count 0) + (original-ensure (symbol-function 'ebox-surface--ensure-node-tree))) + (unwind-protect + (let ((ebox-viewport-width 160) + (ebox-viewport-height 6) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-create + :font-height 1.25 + :width '(viewport) + :ebox-content-node + (ebox-create :content "Inherited"))) + (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-rerender-buffer-with-context buffer 240 6)) + (let ((report (ebox-buffer-update-report buffer))) + (should (> ensured-node-count 0)) + (should-not (plist-get report :projection-kind)) + (should (plist-get report :runtime-published)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-viewport-reflow-falls-back-for-scroll-and-visible-overflow () + "Scroll state and visible overflow must not enter retained reflow." + (dolist (fixture + (list + (cons 'scroll + (lambda () + (ebox-create :content "zero\none\ntwo" + :width '(viewport) + :height 2 + :overflow 'scroll))) + (cons 'visible-overflow + (lambda () + (ebox-create :content "one\ntwo" + :width '(viewport) + :height 1 + :overflow 'visible))))) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer + (format " *ebox-viewport-%s*" (car fixture)))) + (ensured-node-count 0) + (original-ensure (symbol-function 'ebox-surface--ensure-node-tree))) + (unwind-protect + (let ((ebox-viewport-width 160) + (ebox-viewport-height 6) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer buffer (funcall (cdr fixture))) + (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-rerender-buffer-with-context buffer 240 6)) + (let ((report (ebox-buffer-update-report buffer))) + (should (> ensured-node-count 0)) + (should-not (plist-get report :projection-kind)) + (should (plist-get report :runtime-published)))) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + +(ert-deftest ebox-viewport-reflow-supports-height-and-both-axis-resize () + "Retained viewport reflow should cover height-only and two-axis changes." + (dolist (case + '((height 120 3 120 5 height) + (both 160 3 240 5 both))) + (pcase-let ((`(,name ,old-width ,old-height ,new-width ,new-height ,axes) + case)) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer + (format " *ebox-viewport-%s*" name))) + (ensured-node-count 0) + (original-ensure (symbol-function 'ebox-surface--ensure-node-tree))) + (unwind-protect + (let ((ebox-viewport-width old-width) + (ebox-viewport-height old-height) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-create :key name :content "One\nTwo" + :width (if (eq axes 'height) + '(120) + '(viewport)) + :height '(viewport-height) + :overflow 'hidden)) + (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) + (lambda (&rest args) + (cl-incf ensured-node-count) + (apply original-ensure args)))) + (ebox-rerender-buffer-with-context + buffer new-width new-height)) + (let ((report (ebox-buffer-update-report buffer))) + (should (= ensured-node-count 0)) + (should (eq (plist-get report :projection-kind) + 'viewport-reflow)) + (should (eq (plist-get report :viewport-axes) axes)) + (should (plist-get report :runtime-published)) + (with-current-buffer buffer + (should (= (ebox--string-pixel-width + (buffer-substring (line-beginning-position) + (line-end-position))) + new-width))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))))) + +(ert-deftest ebox-viewport-reflow-rolls-back-after-publication-failure () + "A failed retained viewport publication must restore its old generation." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-viewport-rollback*"))) + (unwind-protect + (let ((ebox-viewport-width 160) + (ebox-viewport-height 3) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-create :key 'rollback + :content "zero\none\ntwo\nthree" + :width '(viewport) + :height '(viewport-height) + :overflow 'scroll)) + (let* ((surface (with-current-buffer + buffer ebox-surface--buffer-surface)) + (signals (with-current-buffer + buffer ebox-surface--context-signals)) + (state (tp-surface-client-state surface)) + (old-root (plist-get state :root-node)) + (old-root-object (plist-get old-root :surface-object)) + (old-root-cache (plist-get old-root :render-cache)) + (old-render-cache (plist-get state :render-cache)) + (cache-fingerprints + (mapcar + (lambda (key) + (ebox-surface-test--hash-fingerprint + (plist-get state key))) + '(:render-cache :render-signature-cache + :flex-content-min-widths + :viewport-height-dependent-subtree-cache + :layout-fragments))) + (revision (tp-surface-revision surface)) + (contents (with-current-buffer + buffer + (buffer-substring (point-min) (point-max))))) + (let ((tp--surface-publication-step-function + (lambda (step _surface) + (when (eq step 'client-state) + (error "Reject viewport publication"))))) + (should-error + (ebox-rerender-buffer-with-context buffer 240 5))) + (should (= (tp-surface-revision surface) revision)) + (should (eq (tp-surface-client-state surface) state)) + (should (= (tp-signal-peek + (ebox-surface--signals-viewport-width signals)) + 160)) + (should (= (tp-signal-peek + (ebox-surface--signals-viewport-height signals)) + 3)) + (should (eq (ebox--buffer-render-state buffer) state)) + (should (eq (plist-get state :root-node) old-root)) + (should (eq (plist-get old-root :surface-object) + old-root-object)) + (should (eq (plist-get old-root :render-cache) + old-root-cache)) + (should (eq (plist-get state :render-cache) + old-render-cache)) + (should (equal cache-fingerprints + (mapcar + (lambda (key) + (ebox-surface-test--hash-fingerprint + (plist-get state key))) + '(:render-cache :render-signature-cache + :flex-content-min-widths + :viewport-height-dependent-subtree-cache + :layout-fragments)))) + (should (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + contents)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-surface-context-signals-track-exact-layout-dependencies () "A mounted producer should subscribe only to context it can consume." (ebox-surface-test--reset-render-state) @@ -535,6 +1429,33 @@ (when (buffer-live-p buffer) (kill-buffer buffer)))))) +(ert-deftest ebox-mounted-render-uses-target-display-context () + "A mounted candidate must measure in its target buffer's display context." + (ebox-surface-test--reset-render-state) + (let* ((source (generate-new-buffer " *ebox-context-source*")) + (target (generate-new-buffer " *ebox-context-target*")) + (node (ebox-create :content "MMMM" :width '(100))) + (seen-buffers nil) + (original (symbol-function 'ebox--render-layout))) + (unwind-protect + (progn + (with-current-buffer source + (setq-local text-scale-mode-amount 3)) + (with-current-buffer target + (setq-local text-scale-mode-amount 0)) + (cl-letf (((symbol-function 'ebox--render-layout) + (lambda (candidate) + (push (current-buffer) seen-buffers) + (funcall original candidate)))) + (with-current-buffer source + (ebox-render-to-buffer target node))) + (should seen-buffers) + (should (cl-every (lambda (buffer) (eq buffer target)) + seen-buffers))) + (dolist (buffer (list source target)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + (ert-deftest ebox-commit-publishes-through-the-mounted-tp-surface () "Declarative commits should publish through the mounted TP surface." (ebox-surface-test--reset-render-state)