feat(ebox): project layouts into TP surface plans

Assign retained TP identity before layout and emit pure, runtime-free surface plans with exact character and text-property equivalence. Keep live publication unchanged for the staged cutover and add focused surface, package, docs, and CI contracts.\n\nVerified: make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs\nVerified: WERROR byte compilation for all 16 active Lisp files\nVerified: focused ebox-surface checkdoc has zero warnings
This commit is contained in:
Kinneyzhang 2026-08-06 04:13:13 +08:00
parent bfef45f765
commit fad54d7fb1
16 changed files with 624 additions and 64 deletions

View File

@ -12,6 +12,9 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Check out TP runtime dependency
run: git clone --depth 1 https://github.com/Kinneyzhang/tp.git ../tp
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update sudo apt-get update

View File

@ -1,6 +1,6 @@
# Ebox design # Ebox design
Ebox is the low-level rendering engine. Its job is to turn a declarative node tree into measured layout fragments and then publish those fragments into an Emacs buffer. It is deliberately smaller than an application framework. Ebox is the low-level spatial rendering engine. Its job is to turn a declarative node tree into measured layout fragments and a generic TP surface plan. It is deliberately smaller than an application framework.
## Design principles ## Design principles
@ -19,10 +19,10 @@ node tree
-> measured formatting context -> measured formatting context
-> layout fragments and snapshot -> layout fragments and snapshot
-> render context -> render context
-> buffer patch/publication -> pure TP surface plan
``` ```
The owners are `ebox-tree.el` for identity and traversal, `ebox-style.el` for style semantics, `ebox-measure.el` for display-sensitive measurement, `ebox-layout.el`/`ebox-flex.el`/`ebox-grid.el` for geometry, `ebox-fragment.el` for fragment facts, `ebox-buffer-backend.el` for text properties and buffer edits, and `ebox-incremental.el` for dirty planning and publication. The owners are `ebox-tree.el` for Ebox identity and traversal, `ebox-style.el` for style semantics, `ebox-measure.el` for display-sensitive measurement, `ebox-layout.el`/`ebox-flex.el`/`ebox-grid.el` for geometry, `ebox-fragment.el` for fragment facts, `ebox-surface.el` for pure TP plan projection, and `ebox-incremental.el` for dirty-owner planning. During the staged cutover, the existing Ebox buffer backend still publishes live public entry points; it is not used by the pure projector and will be removed when those entry points move to TP surfaces.
## Public boundary ## Public boundary

View File

@ -1,6 +1,6 @@
# Ebox 设计 # Ebox 设计
Ebox 是底层渲染引擎。它负责把声明式节点树转换为经过测量的布局 fragment,再发布到 Emacs buffer。它有意小于应用框架。 Ebox 是底层空间渲染引擎。它负责把声明式节点树转换为经过测量的布局 fragment 与通用 TP surface plan。它有意小于应用框架。
## 设计原则 ## 设计原则
@ -19,10 +19,10 @@ Ebox 是底层渲染引擎。它负责把声明式节点树转换为经过测量
-> 经过测量的 formatting context -> 经过测量的 formatting context
-> 布局 fragment 与 snapshot -> 布局 fragment 与 snapshot
-> render context -> render context
-> buffer patch/发布 -> 纯 TP surface plan
``` ```
各层 owner 是:`ebox-tree.el` 负责 identity 与遍历,`ebox-style.el` 负责样式语义,`ebox-measure.el` 负责 display 敏感测量,`ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` 负责几何,`ebox-fragment.el` 负责 fragment 事实,`ebox-buffer-backend.el` 负责 text property 与 buffer 编辑,`ebox-incremental.el` 负责 dirty 规划与发布 各层 owner 是:`ebox-tree.el` 负责 Ebox identity 与遍历,`ebox-style.el` 负责样式语义,`ebox-measure.el` 负责 display 敏感测量,`ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` 负责几何,`ebox-fragment.el` 负责 fragment 事实,`ebox-surface.el` 负责纯 TP plan 投影,`ebox-incremental.el` 负责 dirty owner 规划。分阶段切换期间,现有 Ebox buffer backend 仍为公开 live 入口执行发布;纯 projector 不调用它,公开入口切到 TP surface 后会删除这套发布执行器
## 公共边界 ## 公共边界

View File

@ -1,12 +1,14 @@
EMACS ?= emacs EMACS ?= emacs
CARGO ?= cargo CARGO ?= cargo
TP_DIR ?= ../tp
LOAD_EXTRA ?=
EMACS_BATCH = $(EMACS) -Q --batch -L . --eval '(setq load-prefer-newer t)' EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)'
NATIVE_MANIFEST = native/Cargo.toml NATIVE_MANIFEST = native/Cargo.toml
NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') 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 NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release
.PHONY: all check ci load compile test checkdoc core-tests grid-tests ebox-commit-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 visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install
all: check all: check
@ -21,7 +23,7 @@ compile:
rm -f *.elc tests/*.elc scripts/*.elc rm -f *.elc tests/*.elc scripts/*.elc
$(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t)' -l ebox.el --eval '(ebox-byte-compile)' $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t)' -l ebox.el --eval '(ebox-byte-compile)'
test: core-tests grid-tests ebox-commit-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests test: 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
core-tests: core-tests:
$(EMACS_BATCH) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit $(EMACS_BATCH) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit
@ -32,6 +34,9 @@ grid-tests:
ebox-commit-tests: ebox-commit-tests:
$(EMACS_BATCH) -l tests/ebox-commit-tests.el -f ert-run-tests-batch-and-exit $(EMACS_BATCH) -l tests/ebox-commit-tests.el -f ert-run-tests-batch-and-exit
surface-tests:
$(EMACS_BATCH) -l tests/ebox-surface-tests.el -f ert-run-tests-batch-and-exit
visual-check-tests: visual-check-tests:
$(EMACS_BATCH) -l tests/ebox-visual-check-tests.el -f ert-run-tests-batch-and-exit $(EMACS_BATCH) -l tests/ebox-visual-check-tests.el -f ert-run-tests-batch-and-exit

View File

@ -4,9 +4,10 @@ Ebox is a standalone, pixel-precise box and layout engine for Emacs. It provides
## Install and use ## Install and use
Clone or place this directory on `load-path`: 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`:
```elisp ```elisp
(add-to-list 'load-path "/path/to/github/tp")
(add-to-list 'load-path "/path/to/github/ebox") (add-to-list 'load-path "/path/to/github/ebox")
(require 'ebox) (require 'ebox)
@ -39,6 +40,7 @@ ETAF belongs in the sibling package. Install it separately when you need the uni
| `ebox.el` | Public facade and package entry point. | | `ebox.el` | Public facade and package entry point. |
| `ebox-style.el`, `ebox-tree.el`, `ebox-measure.el` | Style, tree, and measurement models. | | `ebox-style.el`, `ebox-tree.el`, `ebox-measure.el` | Style, tree, and measurement models. |
| `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Formatting contexts and layout algorithms. | | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Formatting contexts and layout algorithms. |
| `ebox-surface.el` | Pure Ebox runtime/layout projection into generic TP surface plans. |
| `ebox-buffer-backend.el`, `ebox-incremental.el` | Buffer publication and incremental updates. | | `ebox-buffer-backend.el`, `ebox-incremental.el` | Buffer publication and incremental updates. |
| `ebox-dsl.el`, `ebox-selector.el` | Standalone DSL and tree/runtime queries. | | `ebox-dsl.el`, `ebox-selector.el` | Standalone DSL and tree/runtime queries. |
| `ebox-native-reflow.el`, `native/` | Optional native reflow boundary. | | `ebox-native-reflow.el`, `native/` | Optional native reflow boundary. |
@ -54,6 +56,6 @@ make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make native-rust-tests make native-rust-tests
``` ```
Run the smallest focused target first (`make grid-tests`, `make dsl-tests`, or `make visual-check-tests`), then run `make check` for shared rendering or public API changes. 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.
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. 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.

View File

@ -4,9 +4,10 @@ Ebox 是一个独立的 Emacs 像素级 Box 与布局引擎。它是同级 [ETAF
## 安装与使用 ## 安装与使用
将本目录放到 `load-path` 先安装 TP再通过包管理器安装 Ebox让声明的依赖自动解析。使用同级源码 checkout 时,把两个目录都加入 `load-path`
```elisp ```elisp
(add-to-list 'load-path "/path/to/github/tp")
(add-to-list 'load-path "/path/to/github/ebox") (add-to-list 'load-path "/path/to/github/ebox")
(require 'ebox) (require 'ebox)
@ -39,6 +40,7 @@ ETAF 属于同级独立包。当你需要统一 View 语法、Component、响应
| `ebox.el` | 公共门面与包入口。 | | `ebox.el` | 公共门面与包入口。 |
| `ebox-style.el`、`ebox-tree.el`、`ebox-measure.el` | 样式、树和测量模型。 | | `ebox-style.el`、`ebox-tree.el`、`ebox-measure.el` | 样式、树和测量模型。 |
| `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Formatting Context 与布局算法。 | | `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-buffer-backend.el`、`ebox-incremental.el` | Buffer 发布与增量更新。 |
| `ebox-dsl.el`、`ebox-selector.el` | 独立 DSL 与树/runtime 查询。 | | `ebox-dsl.el`、`ebox-selector.el` | 独立 DSL 与树/runtime 查询。 |
| `ebox-native-reflow.el`、`native/` | 可选 native reflow 边界。 | | `ebox-native-reflow.el`、`native/` | 可选 native reflow 边界。 |
@ -54,6 +56,6 @@ make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make native-rust-tests make native-rust-tests
``` ```
小范围修改先运行对应目标(例如 `make grid-tests`、`make dsl-tests` 或 `make visual-check-tests`);共享渲染或公共 API 修改后运行完整的 `make check` 小范围修改先运行对应目标(例如 `make grid-tests`、`make surface-tests`、`make dsl-tests` 或 `make visual-check-tests`);共享渲染或公共 API 修改后运行完整的 `make check`默认从 `../tp` 查找同级 TP checkout若路径不同可覆盖 `TP_DIR`
公共构造示例见 [Ebox 用户指南](docs/user/ebox-user-guide.zh.md),职责边界与不变量见 [当前实现参考](docs/maintainer/ebox-current-implementation-reference.zh.md)。 公共构造示例见 [Ebox 用户指南](docs/user/ebox-user-guide.zh.md),职责边界与不变量见 [当前实现参考](docs/maintainer/ebox-current-implementation-reference.zh.md)。

View File

@ -24,6 +24,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `ebox-layout.el` | Box, row, column, stack, concatenation, spacer, wrapping, and common formatting context. | | `ebox-layout.el` | Box, row, column, stack, concatenation, spacer, wrapping, and common formatting context. |
| `ebox-flex.el` | Flex normalization, lines, free-space distribution, and flex rendering. | | `ebox-flex.el` | Flex normalization, lines, free-space distribution, and flex rendering. |
| `ebox-grid.el` | Tracks, implicit tracks, fractions, minmax/repeat, gap, placement, span, and alignment. | | `ebox-grid.el` | Tracks, implicit tracks, fractions, minmax/repeat, gap, placement, span, and alignment. |
| `ebox-surface.el` | Pure candidate identity and projection from Ebox layout output to generic TP surface plans. |
| `ebox-buffer-backend.el` | Text properties, display spaces/borders, markers, extents, replacement, and buffer mutation. | | `ebox-buffer-backend.el` | Text properties, display spaces/borders, markers, extents, replacement, and buffer mutation. |
| `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, atomic publication, and reports. | | `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, atomic publication, and reports. |
| `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. | | `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. |
@ -32,7 +33,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
The package intentionally does not include application Components, UI controls, reactive data, or a playground implementation. Those are sibling-package responsibilities. 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-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`, and `scripts/ebox-visual-check.el`.
## Runtime model ## Runtime model
@ -45,6 +46,7 @@ Source Tree
-> Box/Formatting Context -> Box/Formatting Context
-> Measurement + Render Context -> Measurement + Render Context
-> Layout Fragment/Snapshot -> Layout Fragment/Snapshot
-> Pure TP Surface Plan
-> Dirty/Patch Plan -> Dirty/Patch Plan
-> Emacs Buffer Backend -> Emacs Buffer Backend
``` ```
@ -56,6 +58,7 @@ Source Tree
| Measurement | `ebox-measure.el` | Application state or dirty policy. | | Measurement | `ebox-measure.el` | Application state or dirty policy. |
| Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. | | Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. |
| Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. | | Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. |
| Surface Projection | `ebox-surface.el` | Buffer mutation or Ebox layout decisions. |
| Dirty/Patch | `ebox-incremental.el` | Raw measurement or direct buffer edits. | | Dirty/Patch | `ebox-incremental.el` | Raw measurement or direct buffer edits. |
| Buffer Backend | `ebox-buffer-backend.el` | Style semantics or application state. | | Buffer Backend | `ebox-buffer-backend.el` | Style semantics or application state. |
@ -70,6 +73,7 @@ Source Tree
- Buffer coordinates belong to the generation that produced them and must be refreshed after mutation. - 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. - 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. - Loading Ebox never builds or installs the optional Rust module.
- The Phase 7 projector assigns TP candidate identity before layout, emits plans containing no markers, positions, buffers, or TP runtime handles, and is byte-for-byte and property-for-property equivalent to `ebox-render`. Public live entry points still use the existing Ebox backend until the next cutover phase.
## Grid contract ## Grid contract
@ -88,6 +92,7 @@ make check
make core-tests make core-tests
make grid-tests make grid-tests
make ebox-commit-tests make ebox-commit-tests
make surface-tests
make visual-check-tests make visual-check-tests
make package-tests make package-tests
make selector-tests make selector-tests

View File

@ -24,6 +24,7 @@
| `ebox-layout.el` | box、row、column、stack、concat、spacer、换行和通用 formatting context。 | | `ebox-layout.el` | box、row、column、stack、concat、spacer、换行和通用 formatting context。 |
| `ebox-flex.el` | flex 归一化、line、剩余空间分配和 flex 渲染。 | | `ebox-flex.el` | flex 归一化、line、剩余空间分配和 flex 渲染。 |
| `ebox-grid.el` | 轨道、隐式轨道、分数、minmax/repeat、gap、placement、span 和对齐。 | | `ebox-grid.el` | 轨道、隐式轨道、分数、minmax/repeat、gap、placement、span 和对齐。 |
| `ebox-surface.el` | Ebox 候选 identity以及从布局输出到通用 TP surface plan 的纯投影。 |
| `ebox-buffer-backend.el` | text property、display space/border、marker、extent、替换和 buffer 变更。 | | `ebox-buffer-backend.el` | text property、display space/border、marker、extent、替换和 buffer 变更。 |
| `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、原子发布和报告。 | | `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、原子发布和报告。 |
| `ebox-dsl.el` | 数据型 `.ebox` form以及向公共节点的 lowering。 | | `ebox-dsl.el` | 数据型 `.ebox` form以及向公共节点的 lowering。 |
@ -32,7 +33,7 @@
本包有意不包含应用 Component、UI control、响应式 data 或 playground 实现;它们属于同级包。历史应用性能记录器和 native reflow 评估器也不属于独立 Ebox 的发布边界Ebox 只保留 native 模块本身、Rust 构建输入和可重复的构建检查。 本包有意不包含应用 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-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`
## 运行时模型 ## 运行时模型
@ -45,6 +46,7 @@ Source Tree
-> Box/Formatting Context -> Box/Formatting Context
-> Measurement + Render Context -> Measurement + Render Context
-> Layout Fragment/Snapshot -> Layout Fragment/Snapshot
-> 纯 TP Surface Plan
-> Dirty/Patch Plan -> Dirty/Patch Plan
-> Emacs Buffer Backend -> Emacs Buffer Backend
``` ```
@ -56,6 +58,7 @@ Source Tree
| Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 | | Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 |
| Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 | | Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 |
| Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 | | Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 |
| Surface Projection | `ebox-surface.el` | Buffer 变更或 Ebox 布局决策。 |
| Dirty/Patch | `ebox-incremental.el` | 原始测量或直接 buffer 编辑。 | | Dirty/Patch | `ebox-incremental.el` | 原始测量或直接 buffer 编辑。 |
| Buffer Backend | `ebox-buffer-backend.el` | 样式语义或应用状态。 | | Buffer Backend | `ebox-buffer-backend.el` | 样式语义或应用状态。 |
@ -70,6 +73,7 @@ Source Tree
- Buffer 坐标属于生成它的 generation变更后必须重新获取。 - Buffer 坐标属于生成它的 generation变更后必须重新获取。
- Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。 - Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。
- 加载 Ebox 不会构建或安装可选 Rust 模块。 - 加载 Ebox 不会构建或安装可选 Rust 模块。
- Phase 7 projector 在布局前分配 TP candidate identity输出不含 marker、position、buffer 或 TP runtime handle 的 plan并与 `ebox-render` 保持逐字节、逐属性等价。公开 live 入口在下一阶段切换前仍使用现有 Ebox backend。
## Grid 合同 ## Grid 合同
@ -86,6 +90,7 @@ make check
make core-tests make core-tests
make grid-tests make grid-tests
make ebox-commit-tests make ebox-commit-tests
make surface-tests
make visual-check-tests make visual-check-tests
make package-tests make package-tests
make selector-tests make selector-tests

View File

@ -1205,10 +1205,8 @@ node, parent, region, Host, or native-layout indexes."
(plist-put state :selector-index-stale-p nil))) (plist-put state :selector-index-stale-p nil)))
state)) state))
(defun ebox--set-buffer-render-state (buffer root-node) (defun ebox--new-buffer-render-state (root-node)
"Store ROOT-NODE and current render context for BUFFER." "Return fresh buffer-independent runtime state for ROOT-NODE."
(let ((index (ebox--runtime-index root-node t)))
(puthash buffer
(list :root-node root-node (list :root-node root-node
:viewport-width ebox-viewport-width :viewport-width ebox-viewport-width
:viewport-height ebox-viewport-height :viewport-height ebox-viewport-height
@ -1216,38 +1214,38 @@ node, parent, region, Host, or native-layout indexes."
:layout-snapshots (make-hash-table :test 'equal) :layout-snapshots (make-hash-table :test 'equal)
:layout-snapshots-complete-p nil :layout-snapshots-complete-p nil
:layout-snapshot-detail-generation 0 :layout-snapshot-detail-generation 0
:runtime-revision 0 :runtime-revision 0 :last-update-report nil
:last-update-report nil
:render-cache (make-hash-table :test 'equal) :render-cache (make-hash-table :test 'equal)
:detached-identity-history :detached-identity-history
(ebox-incremental--make-detached-history (ebox-incremental--make-detached-history
:table (make-hash-table :test 'equal) :table (make-hash-table :test 'equal)
:order nil :order nil :node-count 0)
:node-count 0)
:render-signature-cache (make-hash-table :test 'eq) :render-signature-cache (make-hash-table :test 'eq)
:flex-content-min-widths (make-hash-table :test 'eq) :flex-content-min-widths (make-hash-table :test 'eq)
:viewport-height-dependent-subtree-cache :viewport-height-dependent-subtree-cache
(make-hash-table :test 'eq) (make-hash-table :test 'eq)
:region-role-span-table nil :region-role-span-table nil))
:node-table (plist-get index :node-table)
:parent-table (plist-get index :parent-table) (defun ebox--render-state-install-index (state index)
:region-id-set (plist-get index :region-id-set) "Install runtime INDEX tables into candidate STATE and return STATE."
:region-node-table (plist-get index :region-node-table) (dolist (key '(:node-table :parent-table :region-id-set :region-node-table
:region-box-count-table :region-box-count-table :region-box-table :host-ref-table
(plist-get index :region-box-count-table) :selector-id-table :selector-class-table
:region-box-table :selector-type-table :native-node-postorder))
(plist-get index :region-box-table) (setq state (plist-put state key (plist-get index key))))
:host-ref-table (plist-get index :host-ref-table) state)
:selector-id-table
(plist-get index :selector-id-table) (defun ebox--make-buffer-render-state (root-node)
:selector-class-table "Return unpublished complete runtime state for ROOT-NODE."
(plist-get index :selector-class-table) (ebox--render-state-install-index
:selector-type-table (ebox--new-buffer-render-state root-node)
(plist-get index :selector-type-table) (ebox--runtime-index root-node t)))
:native-node-postorder
(plist-get index :native-node-postorder)) (defun ebox--set-buffer-render-state (buffer root-node)
"Store ROOT-NODE and current render context for BUFFER."
(puthash buffer (ebox--make-buffer-render-state root-node)
ebox--buffer-render-state-table) ebox--buffer-render-state-table)
(ebox--buffer-viewport-dependent-node-id-axes buffer))) (ebox--buffer-viewport-dependent-node-id-axes buffer))
(defun ebox--buffer-node-table (buffer) (defun ebox--buffer-node-table (buffer)
"Return BUFFER's runtime node table." "Return BUFFER's runtime node table."

231
ebox-surface.el Normal file
View File

@ -0,0 +1,231 @@
;;; ebox-surface.el --- Pure retained surface projection -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Owns the pure boundary from an Ebox candidate runtime and rendered layout to
;; a generic TP surface plan. It never publishes or scans a live buffer.
;;; Code:
(require 'cl-lib)
(require 'ebox-tree)
(require 'ebox-layout)
(require 'ebox-incremental)
(require 'tp-surface)
(defvar ebox-region-types)
(defvar ebox--region-box-table)
(defvar ebox--scroll-global-state)
(defvar ebox--scroll-idle-prefetch-timers)
(defvar ebox--smooth-scroll-state-table)
(declare-function ebox-render "ebox-layout" (node))
(declare-function ebox--scroll-schedule-idle-prefetch
"ebox" (region-id &optional delay))
(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--candidate-root (source previous-state)
"Return an isolated runtime copy of SOURCE reconciled with PREVIOUS-STATE."
(unless (and (listp source) (not (stringp source)))
(error "Ebox surface source must be an Ebox node"))
(ebox-tree-validate-declarative-root source)
(let ((candidate
(ebox-tree-clear-runtime-identities
(ebox-tree-copy-node-structure source))))
(when-let ((previous-root (plist-get previous-state :root-node)))
(ebox-tree-reconcile-runtime previous-root candidate))
candidate))
(defun ebox-surface--node-key (node)
"Return NODE's namespaced sibling key, or nil for positional identity."
(when-let ((key (plist-get node :key)))
(list 'ebox/key key)))
(defun ebox-surface--node-kind (node)
"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."
(let ((object
(tp-object-ensure context parent
(ebox-surface--node-key node)
(ebox-surface--node-kind node))))
(tp-object-retain context object)
(plist-put node :surface-object object)
(puthash node object table)
(dolist (child (ebox-tree--children-raw node))
(ebox-surface--ensure-node-tree context object child table))
object))
(defun ebox-surface--node-object-table (objects-by-node)
"Return a node-id keyed table from OBJECTS-BY-NODE."
(let ((table (make-hash-table :test 'equal)))
(maphash
(lambda (node object)
(let ((node-id (plist-get node :node-id)))
(unless node-id
(error "Ebox rendered node has no runtime identity"))
(puthash node-id object table)))
objects-by-node)
table))
(defun ebox-surface--region-object-table (state node-objects)
"Return region-to-object table from candidate STATE and NODE-OBJECTS."
(let ((table (make-hash-table :test 'equal)))
(maphash
(lambda (region-id node-id)
(let ((object (gethash node-id node-objects)))
(unless object
(error "Ebox region %S has no candidate surface object" region-id))
(puthash region-id object table)))
(plist-get state :region-node-table))
table))
(defun ebox-surface--hash-keys (table)
"Return TABLE keys in unspecified order."
(let (keys)
(maphash (lambda (key _value) (push key keys)) table)
keys))
(defun ebox-surface--render-candidate (state)
"Render candidate STATE in isolated Ebox side tables."
(let ((scroll-table (make-hash-table :test 'equal))
(timer-table (make-hash-table :test 'equal))
(smooth-table (make-hash-table :test 'equal)))
(let ((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))
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (&rest _) nil)))
(prog1 (ebox-render (plist-get state :root-node))
(plist-put state :scroll-state-table scroll-table)
(plist-put state :scroll-region-ids
(ebox-surface--hash-keys scroll-table)))))))
(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)))
(defun ebox-surface--role-ids-at (rendered position)
"Return namespaced Ebox role/id pairs at POSITION in RENDERED."
(let (roles)
(dolist (region-id
(get-text-property position 'ebox-content-owners rendered))
(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)))
(nreverse roles)))
(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
:ebox/roles
(cl-loop for (role . owner) in role-ids
when (equal owner region-id) collect role)))
(defun ebox-surface--attach-fragment
(context fragment role-ids region-objects)
"Attach FRAGMENT to ROLE-IDS owners through REGION-OBJECTS in CONTEXT."
(dolist (region-id (delete-dups (mapcar #'cdr role-ids)))
(let ((object (gethash region-id region-objects)))
(unless object
(error "Ebox output references unknown region %S" region-id))
(tp-object-attach-fragment
context object fragment
(ebox-surface--region-role-tags region-id role-ids)))))
(defun ebox-surface--fragment-plan
(context parent rendered start end index region-objects)
"Return one plan below PARENT for RENDERED START..END at INDEX in CONTEXT."
(let* ((key (cons 'ebox/fragment index))
(object (tp-object-ensure context parent key 'ebox/fragment))
(role-ids (ebox-surface--role-ids-at rendered start))
(tags (list :ebox/fragment index :ebox/role-ids role-ids)))
(ebox-surface--attach-fragment
context object role-ids 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 region-objects)
"Return property-interval plans in CONTEXT for RENDERED below PARENT."
(let ((position 0)
(limit (length rendered))
(index 0)
plans)
(while (< position limit)
(let ((next (or (next-property-change position rendered limit) limit)))
(push (ebox-surface--fragment-plan
context parent rendered position next index region-objects)
plans)
(setq position (max next (1+ position))
index (1+ index))))
(nreverse plans)))
(defun ebox-surface--surface-plan
(context surface-root rendered region-objects)
"Return CONTEXT's plan below SURFACE-ROOT for RENDERED and REGION-OBJECTS."
(let* ((fragment-root
(tp-object-ensure context surface-root
ebox-surface--fragments-key 'ebox/fragments))
(fragments
(ebox-surface--fragment-plans
context fragment-root rendered 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)))
(defun ebox-surface--project (context source previous-state)
"Project SOURCE in CONTEXT using optional PREVIOUS-STATE."
(let* ((root (ebox-surface--candidate-root source previous-state))
(state (ebox--new-buffer-render-state root))
(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)))
(tp-object-retain context node-root)
(ebox-surface--ensure-node-tree context node-root root objects-by-node)
(plist-put state :region-box-table (make-hash-table :test 'equal))
(let ((rendered (ebox-surface--render-candidate state)))
(setq state (ebox-surface--finish-runtime-state state))
(let* ((node-objects (ebox-surface--node-object-table objects-by-node))
(region-objects
(ebox-surface--region-object-table state node-objects)))
(plist-put state :surface-node-object-table node-objects)
(plist-put state :region-surface-object-table region-objects)
(tp-surface-result-create
(ebox-surface--surface-plan
context surface-root rendered region-objects)
state)))))
(defun ebox-surface--producer (source &optional previous-state)
"Return a TP producer for Ebox SOURCE and optional PREVIOUS-STATE."
(lambda (context)
(ebox-surface--project context source previous-state)))
(provide 'ebox-surface)
;;; ebox-surface.el ends here

View File

@ -372,13 +372,15 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(when (eq (plist-get node :ebox-type) 'box) (when (eq (plist-get node :ebox-type) 'box)
(when (plist-member node :region-id) (when (plist-member node :region-id)
(plist-put node :region-id nil))) (plist-put node :region-id nil)))
(when (plist-member node :surface-object)
(plist-put node :surface-object nil))
(dolist (child (ebox-tree--children-raw node)) (dolist (child (ebox-tree--children-raw node))
(visit child))))) (visit child)))))
(visit root)) (visit root))
root) root)
(defconst ebox-tree--runtime-source-keys (defconst ebox-tree--runtime-source-keys
'(:node-id :region-id :render-cache) '(:node-id :region-id :surface-object :render-cache)
"Runtime-owned plist keys excluded from declarative source signatures.") "Runtime-owned plist keys excluded from declarative source signatures.")
(defconst ebox-tree--child-source-keys (defconst ebox-tree--child-source-keys

View File

@ -2,7 +2,7 @@
;; Author: ebox contributors ;; Author: ebox contributors
;; Version: 1.0.1 ;; Version: 1.0.1
;; Package-Requires: ((emacs "29.1")) ;; Package-Requires: ((emacs "29.1") (tp "1.0.0"))
;; Keywords: ui, graphics, convenience ;; Keywords: ui, graphics, convenience
;; URL: https://github.com/ginqi7/ebox ;; URL: https://github.com/ginqi7/ebox
@ -26,7 +26,8 @@
'("ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox-measure.el" '("ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el" "ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el" "ebox-native-reflow.el") "ebox-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el")
"Active Ebox Lisp sources compiled by `ebox-byte-compile'.") "Active Ebox Lisp sources compiled by `ebox-byte-compile'.")
(add-to-list 'load-path ebox--directory) (add-to-list 'load-path ebox--directory)
@ -46,6 +47,7 @@
(require 'ebox-grid) (require 'ebox-grid)
(require 'ebox-buffer-backend) (require 'ebox-buffer-backend)
(require 'ebox-incremental) (require 'ebox-incremental)
(require 'ebox-surface)
(require 'ebox-dsl) (require 'ebox-dsl)
(require 'ebox-selector) (require 'ebox-selector)

View File

@ -17,6 +17,7 @@
(let ((source (ebox-ci-test--read "Makefile"))) (let ((source (ebox-ci-test--read "Makefile")))
(dolist (target '("check:" "ci:" "load:" "compile:" "test:" (dolist (target '("check:" "ci:" "load:" "compile:" "test:"
"core-tests:" "grid-tests:" "ebox-commit-tests:" "core-tests:" "grid-tests:" "ebox-commit-tests:"
"surface-tests:"
"docs-contract-tests:" "docs-contract-tests:"
"ci-contract-tests:" "native-rust-tests:" "ci-contract-tests:" "native-rust-tests:"
"native-build:" "diff-check:")) "native-build:" "diff-check:"))
@ -44,6 +45,7 @@
"GitHub Actions should call the same local checks as maintainers." "GitHub Actions should call the same local checks as maintainers."
(let ((source (ebox-ci-test--read ".github/workflows/ci.yml"))) (let ((source (ebox-ci-test--read ".github/workflows/ci.yml")))
(should (string-match-p "emacs-nox" source)) (should (string-match-p "emacs-nox" source))
(should (string-match-p "Kinneyzhang/tp" source))
(should (string-match-p "make ci" source)) (should (string-match-p "make ci" source))
(should (string-match-p (regexp-quote "cargo +stable test") source)) (should (string-match-p (regexp-quote "cargo +stable test") source))
(should (string-match-p (regexp-quote "cargo +1.82.0 test") source)))) (should (string-match-p (regexp-quote "cargo +1.82.0 test") source))))

View File

@ -20,10 +20,10 @@
"ebox.el" "ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox.el" "ebox-cache.el" "ebox-style.el" "ebox-tree.el"
"ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el"
"ebox-layout.el" "ebox-flex.el" "ebox-grid.el" "ebox-layout.el" "ebox-flex.el" "ebox-grid.el"
"ebox-buffer-backend.el" "ebox-incremental.el" "ebox-dsl.el" "ebox-buffer-backend.el" "ebox-incremental.el" "ebox-surface.el" "ebox-dsl.el"
"ebox-selector.el" "ebox-native-reflow.el" "ebox-selector.el" "ebox-native-reflow.el"
"tests/ebox-core-render-tests.el" "tests/ebox-grid-tests.el" "tests/ebox-core-render-tests.el" "tests/ebox-grid-tests.el"
"tests/ebox-commit-tests.el" "tests/ebox-commit-tests.el" "tests/ebox-surface-tests.el"
"tests/ebox-dsl-tests.el" "tests/ebox-flex-tests.el" "tests/ebox-dsl-tests.el" "tests/ebox-flex-tests.el"
"tests/ebox-selector-tests.el" "tests/ebox-package-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-visual-check-tests.el" "tests/ebox-docs-contract-tests.el"
@ -36,7 +36,7 @@
(defconst ebox-docs-test--targets (defconst ebox-docs-test--targets
'("check" "ci" "load" "compile" "core-tests" "grid-tests" '("check" "ci" "load" "compile" "core-tests" "grid-tests"
"ebox-commit-tests" "visual-check-tests" "package-tests" "ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests"
"selector-tests" "dsl-tests" "flex-tests" "selector-tests" "dsl-tests" "flex-tests"
"docs-contract-tests" "ci-contract-tests" "visual-check" "docs-contract-tests" "ci-contract-tests" "visual-check"
"native-rust-tests" "native-build" "package-lint" "diff-check") "native-rust-tests" "native-build" "package-lint" "diff-check")

View File

@ -49,7 +49,7 @@
(dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure (dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure
ebox-fragment ebox-layout ebox-flex ebox-grid ebox-fragment ebox-layout ebox-flex ebox-grid
ebox-buffer-backend ebox-incremental ebox-buffer-backend ebox-incremental
ebox-dsl ebox-selector)) ebox-surface ebox-dsl ebox-selector))
(should (featurep feature)))) (should (featurep feature))))
(ert-deftest ebox-facade-exposes-native-workflow-without-loading-it () (ert-deftest ebox-facade-exposes-native-workflow-without-loading-it ()
@ -67,9 +67,18 @@
'("ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox-measure.el" '("ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el" "ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el"
"ebox-dsl.el" "ebox-selector.el" "ebox.el" "ebox-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el"
"ebox-native-reflow.el")))) "ebox-native-reflow.el"))))
(ert-deftest ebox-surface-depends-only-on-public-tp-api ()
"The Ebox projection boundary should never call private TP symbols."
(let ((source
(with-temp-buffer
(insert-file-contents
(expand-file-name "ebox-surface.el" ebox-test--root))
(buffer-string))))
(should-not (string-match-p "\\_<tp--" source))))
(ert-deftest ebox-byte-compile-rebuilds-the-declared-source-set () (ert-deftest ebox-byte-compile-rebuilds-the-declared-source-set ()
"The interactive command should recompile every declared source in order." "The interactive command should recompile every declared source in order."
(require 'bytecomp) (require 'bytecomp)

294
tests/ebox-surface-tests.el Normal file
View File

@ -0,0 +1,294 @@
;;; ebox-surface-tests.el --- TP surface projection tests -*- lexical-binding: t; -*-
(require 'cl-lib)
(require 'ert)
(unless load-file-name
(error "This test file must be loaded from disk, not eval'ed directly"))
(setq load-prefer-newer t)
(load-file (expand-file-name "../ebox.el"
(file-name-directory load-file-name)))
(require 'tp-surface)
(defun ebox-surface-test--reset-render-state ()
"Reset render identities and side tables used by projection tests."
(setq ebox--region-id-counter 0
ebox--runtime-node-id-counter 0)
(dolist (table (list ebox--region-box-table
ebox--scroll-global-state
ebox--rendered-root-metadata-table))
(clrhash table)))
(defun ebox-surface-test--interactive-content ()
"Return fresh interactive propertized content for projection tests."
(let ((map (make-sparse-keymap)))
(define-key map [mouse-1] #'ignore)
(propertize "Open" 'keymap map 'mouse-face 'highlight
'help-echo "Open this item")))
(defun ebox-surface-test--fixtures ()
"Return named fresh layout builders covering active Ebox layout kinds."
(list
(cons 'box
(lambda ()
(ebox-create :key 'box :content (ebox-surface-test--interactive-content)
:width '(120) :padding '(1 (4))
:border "#334155" :bgcolor "#E2E8F0"
:color "#0F172A")))
(cons 'row-column
(lambda ()
(ebox-column
(ebox-row
(ebox-create :key 'left :content "Left" :width '(70)
:bgcolor "#DBEAFE" :color "#172554")
(ebox-create :key 'right :content "Right\nDetail" :width '(90)
:bgcolor "#DCFCE7" :color "#14532D"))
(ebox-create :key 'footer :content "Footer" :width '(160)
:bgcolor "#F1F5F9" :color "#0F172A"))))
(cons 'flex
(lambda ()
(ebox-flex
:width '(210) :flex-wrap 'wrap :column-gap '(10)
(ebox-flex-item
(ebox-create :key 'grow :content "Grow" :width '(80)
:bgcolor "#EDE9FE" :color "#2E1065")
:flex-grow 1 :flex-basis '(80))
(ebox-flex-item
(ebox-create :key 'fixed :content "Fixed" :width '(120)
:bgcolor "#FFEDD5" :color "#7C2D12")))))
(cons 'grid
(lambda ()
(ebox-grid
:width '(220) :grid-template-columns '((70) 1fr)
:grid-template-rows '(2) :gap '(1 (8))
:border "#475569"
(ebox-create :key 'grid-left :content "A\nAA"
:bgcolor "#E0F2FE" :color "#0C4A6E")
(ebox-create :key 'grid-right :content "B\nBB"
:bgcolor "#FCE7F3" :color "#831843"))))
(cons 'overflow-scroll
(lambda ()
(ebox-create :key 'scroll :content "zero\none\ntwo\nthree"
:width '(100) :height 2 :overflow 'scroll
:bgcolor "#1E293B" :color "#F8FAFC")))))
(defun ebox-surface-test--render-fresh (builder projector-p)
"Render BUILDER after a reset, using the TP projector when PROJECTOR-P."
(ebox-surface-test--reset-render-state)
(let ((ebox-viewport-width 240)
(ebox-viewport-height 12)
(node (funcall builder)))
(if projector-p
(tp-surface-materialize-string
(ebox-surface--producer node))
(ebox-render node))))
(defun ebox-surface-test--walk-runtime (node function)
"Call FUNCTION for every runtime NODE in preorder."
(funcall function node)
(dolist (child (ebox-tree-node-children node))
(ebox-surface-test--walk-runtime child function)))
(defun ebox-surface-test--plan-runtime-value-p (value)
"Return non-nil when VALUE is forbidden runtime state in a pure plan."
(cond
((or (markerp value) (bufferp value) (tp-object-p value)
(tp-binding-p value) (tp-surface-p value))
t)
((consp value)
(or (ebox-surface-test--plan-runtime-value-p (car value))
(ebox-surface-test--plan-runtime-value-p (cdr value))))
((vectorp value)
(cl-some #'ebox-surface-test--plan-runtime-value-p value))
(t nil)))
(defun ebox-surface-test--plan-pure-p (plan)
"Return non-nil when PLAN contains only pure projection data."
(and
(not (cl-some
#'ebox-surface-test--plan-runtime-value-p
(list (tp-surface-plan-key plan)
(tp-surface-plan-kind plan)
(tp-surface-plan-text plan)
(tp-surface-plan-props plan)
(tp-surface-plan-tags plan))))
(cl-every #'ebox-surface-test--plan-pure-p
(tp-surface-plan-children plan))))
(defun ebox-surface-test--object-by-key (state key)
"Return the candidate surface object for Ebox node KEY in STATE."
(let (object)
(maphash
(lambda (_node-id node)
(when (equal (plist-get node :key) key)
(setq object (plist-get node :surface-object))))
(plist-get state :node-table))
object))
(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))
(let* ((builder (cdr fixture))
(expected (ebox-surface-test--render-fresh builder nil))
(actual (ebox-surface-test--render-fresh builder t)))
(should (equal-including-properties actual expected))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines actual))
(mapcar #'ebox--string-pixel-width
(ebox-string-lines expected)))))))
(ert-deftest ebox-surface-assigns-object-identity-before-layout ()
"Every candidate runtime node should own a TP object before layout starts."
(let ((original (symbol-function 'ebox-render))
checked captured)
(cl-letf (((symbol-function 'ebox-render)
(lambda (node)
(unless checked
(setq checked t)
(ebox-surface-test--walk-runtime
node
(lambda (runtime-node)
(let ((object (plist-get runtime-node :surface-object)))
(should (tp-object-p object))
(push object captured)))))
(funcall original node))))
(ebox-surface-test--render-fresh
(cdr (assq 'grid (ebox-surface-test--fixtures))) t))
(should checked)
(should captured)
(dolist (object captured)
(should-not (tp-object-live-p object)))))
(ert-deftest ebox-surface-plan-stays-free-of-runtime-state ()
"The generic surface plan should not contain markers or runtime handles."
(let ((original (symbol-function 'tp-surface-result-create))
captured)
(cl-letf (((symbol-function 'tp-surface-result-create)
(lambda (plan &optional client-state)
(setq captured plan)
(funcall original plan client-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))))
(ert-deftest ebox-surface-projection-does-not-mutate-a-buffer ()
"Pure projection should not call any final buffer mutation primitive."
(cl-letf (((symbol-function 'insert)
(lambda (&rest _) (error "Unexpected buffer insertion")))
((symbol-function 'erase-buffer)
(lambda (&rest _) (error "Unexpected buffer erase")))
((symbol-function 'delete-region)
(lambda (&rest _) (error "Unexpected buffer deletion")))
((symbol-function 'replace-region-contents)
(lambda (&rest _) (error "Unexpected buffer replacement"))))
(should
(stringp
(ebox-surface-test--render-fresh
(cdr (assq 'row-column (ebox-surface-test--fixtures))) t)))))
(ert-deftest ebox-surface-logical-box-owns-disjoint-render-fragments ()
"One logical Ebox box should resolve all of its separated painted regions."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-fragments*"))
surface)
(unwind-protect
(progn
(setq surface
(tp-surface-mount
buffer
(ebox-surface--producer
(funcall (cdr (assq 'box (ebox-surface-test--fixtures)))))
'(:capability content)))
(let* ((state (tp-surface-client-state surface))
(objects (plist-get state :region-surface-object-table))
logical)
(maphash (lambda (_region-id object)
(unless logical (setq logical object)))
objects)
(should (tp-object-live-p logical))
(should (> (length (tp-object-mounts logical)) 1))))
(when (and surface (tp-surface-live-p surface))
(tp-surface-unmount surface))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-surface-reuses-one-source-with-isolated-runtime-state ()
"One source description should mount into two independent TP surfaces."
(ebox-surface-test--reset-render-state)
(let* ((source
(ebox-create :key 'shared :content "Shared" :width '(100)
:bgcolor "#E2E8F0" :color "#0F172A"))
(first-buffer (generate-new-buffer " *ebox-surface-first*"))
(second-buffer (generate-new-buffer " *ebox-surface-second*"))
first second)
(unwind-protect
(progn
(setq first
(tp-surface-mount
first-buffer (ebox-surface--producer source)
'(:capability content)))
(setq second
(tp-surface-mount
second-buffer (ebox-surface--producer source)
'(:capability content)))
(let* ((first-state (tp-surface-client-state first))
(second-state (tp-surface-client-state second))
(first-root (plist-get first-state :root-node))
(second-root (plist-get second-state :root-node)))
(should-not (eq first-root second-root))
(should-not (eq (plist-get first-root :surface-object)
(plist-get second-root :surface-object)))
(should-not (plist-member source :node-id))
(should-not (plist-member source :region-id))
(should-not (plist-member source :surface-object))))
(dolist (surface (list first second))
(when (and surface (tp-surface-live-p surface))
(tp-surface-unmount surface)))
(dolist (buffer (list first-buffer second-buffer))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-surface-keyed-reorder-retains-logical-objects ()
"A keyed child reorder should retain TP objects through a new Ebox runtime."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-reorder*"))
surface)
(unwind-protect
(let* ((first-source
(ebox-row
(ebox-create :key 'left :content "Left" :width '(60))
(ebox-create :key 'right :content "Right" :width '(60))))
(_mount
(setq surface
(tp-surface-mount
buffer (ebox-surface--producer first-source)
'(:capability content))))
(first-state (tp-surface-client-state surface))
(left (ebox-surface-test--object-by-key first-state 'left))
(right (ebox-surface-test--object-by-key first-state 'right))
(next-source
(ebox-row
(ebox-create :key 'right :content "Right!" :width '(60))
(ebox-create :key 'left :content "Left!" :width '(60)))))
(tp-surface-update
surface (ebox-surface--producer next-source first-state))
(let ((next-state (tp-surface-client-state surface)))
(should (eq left
(ebox-surface-test--object-by-key next-state 'left)))
(should (eq right
(ebox-surface-test--object-by-key next-state 'right)))
(should (string-match-p
"Right!.*Left!"
(substring-no-properties
(with-current-buffer buffer (buffer-string)))))))
(when (and surface (tp-surface-live-p surface))
(tp-surface-unmount surface))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(provide 'ebox-surface-tests)
;;; ebox-surface-tests.el ends here