Complete retained Ebox publication and scrolling

This commit is contained in:
Kinneyzhang 2026-08-22 06:18:56 +08:00
parent 972126e239
commit 3fd63989e8
36 changed files with 9150 additions and 586 deletions

View File

@ -22,7 +22,7 @@ node tree
-> pure TP surface plan
```
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 retained publication, and `ebox-incremental.el` for dirty-owner planning. `ebox-buffer-backend.el` only builds and reshapes propertized render strings; all public live buffer publication routes through the TP surface boundary.
The owners are `ebox-child-range.el` for immutable child-sequence and key indexes, `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 retained publication, and `ebox-incremental.el` for dirty-owner planning. `ebox-buffer-backend.el` only builds and reshapes propertized render strings; all public live buffer publication routes through the TP surface boundary.
## Public boundary

View File

@ -22,7 +22,7 @@ Ebox 是底层空间渲染引擎。它负责把声明式节点树转换为经过
-> 纯 TP surface plan
```
各层 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 投影与 retained 发布,`ebox-incremental.el` 负责 dirty owner 规划。`ebox-buffer-backend.el` 只构造和整形带文本属性的 render string所有公共 live buffer 发布路径都经过 TP surface 边界。
各层 owner 是:`ebox-child-range.el` 负责不可变 child sequence 与 key index`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 投影与 retained 发布,`ebox-incremental.el` 负责 dirty owner 规划。`ebox-buffer-backend.el` 只构造和整形带文本属性的 render string所有公共 live buffer 发布路径都经过 TP surface 边界。
## 公共边界

View File

@ -1,7 +1,7 @@
EMACS ?= emacs
CARGO ?= cargo
TP_DIR ?= ../tp
ECSS_DIR ?= ../new-architecture/ecss
ECSS_DIR ?= ../ecss
LOAD_EXTRA ?=
EMACS_BATCH = $(EMACS) -Q --batch -L . -L $(ECSS_DIR) -L $(TP_DIR) $(LOAD_EXTRA) --eval '(setq load-prefer-newer t)'
@ -9,7 +9,7 @@ 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 performance-evaluator 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 child-range-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
@ -24,11 +24,14 @@ compile:
rm -f *.elc tests/*.elc scripts/*.elc
$(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 surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests
test: core-tests child-range-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:
$(EMACS_BATCH) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit
child-range-tests:
$(EMACS_BATCH) -l tests/ebox-child-range-tests.el -f ert-run-tests-batch-and-exit
grid-tests:
$(EMACS_BATCH) -l tests/ebox-grid-tests.el -f ert-run-tests-batch-and-exit

View File

@ -24,6 +24,8 @@ ECSS and TP are independent packages and may be installed in either order. Insta
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 without publishing buffer state (using isolated pure materialization when no cascade/inheritance dependency requires a TP object tree); `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.
Material child lists may contain `ebox-child-range` descriptors, and logical candidates update their base-bound addresses with `ebox-candidate-replace-range-ref`. Candidates may also replace the private mounted root with `ebox-candidate-replace-root`; the last root replacement absorbs descendant operations and never shares an address with `:host-ref`. Reports expose `:range-metrics`, framework participant diagnostics, and scroll finalization diagnostics as read-only snapshots.
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
@ -41,7 +43,7 @@ ETAF belongs in the sibling package. Install it separately when you need the uni
| Path | Responsibility |
| --- | --- |
| `ebox.el` | Public facade and package entry point. |
| `ebox-cache.el`, `ebox-style.el`, `ebox-tree.el`, `ebox-measure.el` | Cache, style, tree, and measurement models. |
| `ebox-cache.el`, `ebox-style.el`, `ebox-tree.el`, `ebox-child-range.el`, `ebox-measure.el` | Cache, style, tree, persistent child-sequence, and measurement models. |
| `ebox-fragment.el`, `ebox-render-context.el` | Layout facts and render-local context. |
| `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. |

View File

@ -24,6 +24,8 @@ ECSS 与 TP 是互相独立的包,安装顺序任意。两者都安装后再
公共模型是一棵 Ebox 节点树。`ebox-create` 构造叶子或包装节点;`ebox-column`、`ebox-row`、`ebox-flex` 和 `ebox-grid` 组合节点;`ebox-render` 生成带属性文本但不发布 buffer state没有 cascade/inheritance 依赖时使用隔离的 pure materialization`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 的宏。
Material child list 可包含 `ebox-child-range` descriptorlogical candidate 用 `ebox-candidate-replace-range-ref` 更新其 base-bound 地址。Candidate 也可用 `ebox-candidate-replace-root` 替换私有 mounted root最后一次 root replacement 会吸收 descendant operation并且不会与 `:host-ref` 共用地址。报告通过 `:range-metrics`、framework participant diagnostics 和 scroll finalization diagnostics 暴露只读快照。
CSS-like selector 字符串会编译为 ECSS 的 structured selector AST。Ebox 提供逻辑节点关系与索引候选;对于 tree query、rendered-buffer query 和样式规则ECSS 是唯一 selector matcher 与 cascade engine。
## 本包负责什么
@ -41,7 +43,7 @@ ETAF 属于同级独立包。当你需要统一 View 语法、Component、响应
| 路径 | 职责 |
| --- | --- |
| `ebox.el` | 公共门面与包入口。 |
| `ebox-cache.el`、`ebox-style.el`、`ebox-tree.el`、`ebox-measure.el` | 缓存、样式、树和测量模型。 |
| `ebox-cache.el`、`ebox-style.el`、`ebox-tree.el`、`ebox-child-range.el`、`ebox-measure.el` | 缓存、样式、树、持久 child sequence 和测量模型。 |
| `ebox-fragment.el`、`ebox-render-context.el` | 布局事实与 render-local context。 |
| `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Formatting Context 与布局算法。 |
| `ebox-surface.el` | 将 Ebox runtime/layout 投影为 TP surface plan并负责 retained mount/update 与原子 runtime-state 参与。 |

View File

@ -1,6 +1,6 @@
# Ebox current implementation reference
This is the maintainer entry point for the standalone Ebox repository. It describes the package boundary, active files, runtime model, invariants, and verification commands. The historical `emacs-box` checkout is a separate legacy source tree; it is not a dependency of this package. ETAF is the sibling higher-level package.
This is the maintainer entry point for the standalone Ebox repository. It describes the package boundary, active files, runtime model, invariants, and verification commands. The historical Ebox checkout is a separate legacy source tree; it is not a dependency of this package. ETAF is the sibling higher-level package.
## Reading order
@ -20,6 +20,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. |
| `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-child-range.el` | Immutable weighted segment trie, sparse persistent key trie, Range replacement, and Gate A metrics. |
| `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. |
@ -35,7 +36,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 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`.
The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/ebox-core-render-tests.el`, `tests/ebox-child-range-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
@ -58,7 +59,7 @@ Caller-owned Source Tree
| Model | Owner | Must not own |
| --- | --- | --- |
| Source/Element Tree | `ebox-tree.el`, `ebox-dsl.el` | Published buffer mutation. |
| Source/Element Tree | `ebox-child-range.el`, `ebox-tree.el`, `ebox-dsl.el` | Published buffer mutation. |
| Computed Style | `ebox-style.el` | Layout identity or patch execution. |
| Measurement | `ebox-measure.el` | Application state or dirty policy. |
| Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. |
@ -100,6 +101,7 @@ make load
make compile
make check
make core-tests
make child-range-tests
make grid-tests
make ebox-commit-tests
make surface-tests

View File

@ -1,6 +1,6 @@
# Ebox 当前实现参考
本文是独立 Ebox 仓库的维护者入口描述包边界、active 文件、运行时模型、不变量和验证命令。历史 `emacs-box` 是另一个旧架构源码树不是本包依赖。ETAF 是同级高层包。
本文是独立 Ebox 仓库的维护者入口描述包边界、active 文件、运行时模型、不变量和验证命令。历史 Ebox checkout 是另一个旧架构源码树不是本包依赖。ETAF 是同级高层包。
## 阅读顺序
@ -20,6 +20,7 @@
| `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 |
| `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 |
| `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 |
| `ebox-child-range.el` | 不可变 weighted segment trie、稀疏持久 key trie、Range replacement 与 Gate A metrics。 |
| `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 |
| `ebox-fragment.el` | 布局 fragment、signature、snapshot、span 和 dirty kind 事实。 |
| `ebox-render-context.el` | render-local context 与发布输入。 |
@ -35,7 +36,7 @@
本包有意不包含应用 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` 和 `scripts/ebox-performance-evaluator.el`
active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-core-render-tests.el`、`tests/ebox-child-range-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`
## 运行时模型
@ -58,7 +59,7 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
| 模型 | Owner | 不得拥有 |
| --- | --- | --- |
| Source/Element Tree | `ebox-tree.el`、`ebox-dsl.el` | 已发布 buffer 的变更。 |
| Source/Element Tree | `ebox-child-range.el`、`ebox-tree.el`、`ebox-dsl.el` | 已发布 buffer 的变更。 |
| Computed Style | `ebox-style.el` | 布局 identity 或 patch 执行。 |
| Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 |
| Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 |
@ -98,6 +99,7 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
```sh
make check
make core-tests
make child-range-tests
make grid-tests
make ebox-commit-tests
make surface-tests

View File

@ -43,6 +43,20 @@ When selected, the candidate keeps the published Ebox topology and reuses the re
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.
## Retained scroll window
For a chrome-free root scroll owner whose cached rendered lines cover the
current visible window (the lazy prefix may remain incomplete),
the scroll patch joins the staged visible window directly instead of entering
the full candidate layout. It reuses the published cascade, viewport axes,
object maps, and descendant `region-box-table`; it only advances the scroll
offset and installs the root `ebox-scroll-window` property. The proof is
conservative: a cache miss, chrome, active style dependency, topology change,
or incomplete window falls back to the ordinary retained path. Scroll
publication still goes through TP and the Ebox participant, so a failure after
TP client-state publication restores text, offsets, indexes, objects, mounts,
and report together.
## 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.

View File

@ -43,6 +43,17 @@
任意 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。
## 保留式 scroll window
对于 chrome-free、缓存 rendered lines 已覆盖当前可见窗口的 root scroll owner
lazy prefix 可以仍未完整 materializescroll patch 会
直接从 staged visible window 拼接输出,不再进入完整 candidate layout。它复用已
发布的 cascade、viewport axes、object maps 和 descendant `region-box-table`,只推进
scroll offset 并安装 root `ebox-scroll-window` 属性。证明保持保守cache miss、
chrome、active style dependency、topology 变化或窗口不完整时,回退到普通 retained
路径。Scroll publication 仍经过 TP 与 Ebox participant即使 TP client-state 发布
之后失败也会一起恢复文本、offset、index、object、mount 和 report。
## Identity 与坐标
Key 只在同级兄弟集合内有效。声明式 source tree 不拥有 live TP object、marker 或 buffer 坐标。live 逻辑 `:id` 通过 `ebox-region-resolve` 解析为绑定某个 TP surface object 的不透明 handle同一 source 挂载到两个 buffer 时会得到不同 handle。Region 与 host-ref 位置绑定于 generationbuffer 变更后调用者必须通过公共 accessor 重新获得位置。Buffer marker、display span 和 text property 是发布事实,不是 source tree identity。

View File

@ -1,6 +1,6 @@
# 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.
> Status: Architecture diagnosis and implementation checkpoint. This document records the performance gap between the current refactored `ebox` and the historical Ebox checkout, 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
>
@ -8,7 +8,7 @@
## 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.
Why does the current refactored Ebox fail to match the performance of the pre-refactor historical Ebox checkout during dynamic content updates, viewport resize, and pure `ebox-render`? The data-flow and ownership causes must be established before optimizing individual functions.
## Conclusion
@ -21,7 +21,7 @@ The key distinction is that currents “local patch” describes publication
## Scope and terminology
- “current” means the standalone refactored `ebox` repository.
- “legacy” means the sibling historical `emacs-box` source tree.
- “legacy” means the sibling historical Ebox checkout.
- “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.
@ -208,9 +208,9 @@ The current retained data flow crosses these boundaries:
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.
- The historical checkout's `ebox.el:7195` directly modifies the target region box and enters the local dirty/patch path.
- The historical checkout's `ebox.el:7399` formats only the target content before applying the local patch.
- The historical checkout's `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

View File

@ -1,6 +1,6 @@
# Ebox 性能架构诊断与重构方向
> 状态:架构诊断与实现 checkpoint。本文记录 current refactored Ebox 相比历史 `emacs-box` 的性能差异、证据边界和高层重构方向。五个范围很窄的 slice 已经实现mounted retained-update 架构尚不能认为已经普遍修复。
> 状态:架构诊断与实现 checkpoint。本文记录 current refactored Ebox 相比历史 Ebox checkout 的性能差异、证据边界和高层重构方向。五个范围很窄的 slice 已经实现mounted retained-update 架构尚不能认为已经普遍修复。
>
> 日期2026-08-07
>
@ -8,7 +8,7 @@
## 问题
为什么 current refactored Ebox 在动态内容更新、viewport resize 和纯 `ebox-render` 场景下,性能达不到重构前的 `emacs-box`?需要先确定数据流和 ownership 层面的主因,而不是继续优化某个具体函数。
为什么 current refactored Ebox 在动态内容更新、viewport resize 和纯 `ebox-render` 场景下,性能达不到历史 Ebox checkout?需要先确定数据流和 ownership 层面的主因,而不是继续优化某个具体函数。
## 结论
@ -21,7 +21,7 @@
## 比较范围与术语
- “current”指独立重构版 `ebox` 仓库。
- “legacy”指同级历史 `emacs-box` 源码树
- “legacy”指同级历史 Ebox checkout
- “pure materialization”指从 source tree 产生带文本属性的渲染字符串,不需要 live buffer、稳定 retained identity 或 rollback。
- “retained publication”指向已挂载 TP surface 发布候选状态,包含稳定 identity、ownership、scope proof、revision 和 rollback 语义。
- 文中的绝对耗时来自同一个本地测量环境;架构判断依赖阶段占比、调用次数和相同输出结果,不依赖某一个绝对时间阈值。
@ -208,9 +208,9 @@ current 的 retained 数据流由以下边界组成:
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。
- 历史 checkout 的 `ebox.el:7195` 直接修改目标 region box并进入局部 dirty/patch 路径。
- 历史 checkout 的 `ebox.el:7399` 只格式化目标内容后执行局部 patch。
- 历史 checkout 的 `ebox.el:7578` 直接 render、insert 并刷新 marker/span。
- legacy 的 `ebox-style.el` 主要做本地属性展开和 dirty 分类,没有 current 的 ECSS stylesheet/reactive per-node cascade。
## Evidence 与 Inference 的边界

View File

@ -76,8 +76,8 @@ use `ebox-render-to-buffer` when the caller owns window selection.
(ebox-create &rest properties)
(ebox-concat node-1 node-2) ; two nodes side by side
(ebox-stack node-1 node-2) ; two nodes vertically
(ebox-row &rest nodes) ; many nodes side by side
(ebox-column &rest nodes) ; many nodes vertically
(ebox-row &rest nodes) ; many nodes side by side; a sole Range keeps its row parent
(ebox-column &rest nodes) ; many nodes vertically; a sole Range keeps its column parent
(ebox-spacer &rest properties) ; blank box
```
@ -332,12 +332,29 @@ one commit:
(ebox-commit buffer candidate))
```
Frameworks that retain semantic owners separately from backend anchors can
use `ebox-range-ref-present-p` as a read-only check before choosing a wider
published owner. It returns nil when the semantic Range is nested inside a
material anchor and therefore is not independently addressable in the current
Ebox publication.
`ebox-candidate-replace-host-ref` uses an application-owned `:host-ref`
instead of a runtime node id. Both replacement functions accept optional
`old-semantic-key` and `new-semantic-key` pairs for bounded detached identity
reuse. A candidate is sealed by commit and cannot be reused; it also becomes
stale if the captured buffer runtime or buffer tick changes.
`ebox-candidate-replace-root` targets a private candidate-bound root address.
It accepts exactly one declarative node, clears caller runtime identities, and
is last-wins. The final root replacement absorbs descendant node and host-ref
operations recorded before or after it, with no wrapper or public reference.
`ebox-child-range` creates a non-node segment descriptor in a material child
list. Its non-nil ref is root-global and its items participate directly in the
parent's key, selector, style, and layout scope. Use
`ebox-candidate-replace-range-ref` with a proper declarative node list to
replace that base payload. Empty payloads remain addressable.
### Host-reference positions
```elisp
@ -359,6 +376,13 @@ mount has no update report until its first update. Reports include the Ebox
strategy and publication scope plus TP surface revision and physical
operation/reconciliation facts.
The complete commit signature is `(ebox-commit BUFFER NEXT-ROOT &optional
FRAMEWORK-PUBLISH FRAMEWORK-ROLLBACK)`. Publish receives the report after the
buffer, TP surface, and Ebox runtime agree. If a later phase fails, rollback
receives that same report at most once; rollback errors and quits are contained.
`:framework-participant-state`, `:framework-participant-diagnostics`, and
`:scroll-finalization-diagnostics` are read-only outcome fields.
Use `ebox-rerender-buffer-with-context` when a root depends on a new viewport:
```elisp
@ -455,9 +479,9 @@ The main customization variables are:
| Group | Variables and defaults |
| --- | --- |
| Render cache | `ebox-render-cache-max-entries` 2048; `ebox-render-cache-max-bytes` 32 MiB; `ebox-render-root-cache-max-entries` 16; `ebox-render-root-cache-max-bytes` 8 MiB |
| Keyboard/mouse scroll | `ebox-scroll-step` 1; `ebox-wheel-scroll-step` 16; `ebox-wheel-smooth-scroll` t; `ebox-wheel-smooth-scroll-interval` 0.016; `ebox-wheel-smooth-scroll-lines-per-tick` 4; `ebox-wheel-smooth-scroll-target-ticks` 8 |
| Keyboard/mouse scroll | `ebox-scroll-step` 1; `ebox-wheel-scroll-step` 1; `ebox-wheel-smooth-scroll` nil; `ebox-wheel-smooth-scroll-interval` 0.016; `ebox-wheel-smooth-scroll-lines-per-tick` 4; `ebox-wheel-smooth-scroll-target-ticks` 8. Keyboard uses point; wheel uses event position; residual lines bubble through nested owners. An interactive complete, chrome-free root document owner may be idle-materialized once and then use Emacs's native window line start; ordinary updates/resizes only warm retained indexes and never start a second native publication; nested/lazy/chrome owners retain transactional Ebox publication. |
| Lazy scroll | `ebox-scroll-lazy-prefix-lookahead-lines` 8; `ebox-scroll-lazy-idle-prefetch-lines` 128; `ebox-scroll-lazy-idle-prefetch-slice-lines` 16; `ebox-scroll-lazy-idle-prefetch-delay` 0.15 |
| Runtime prewarm | `ebox-runtime-idle-prewarm` t; `ebox-runtime-idle-prewarm-delay` 0.1; `ebox-runtime-idle-prewarm-prefix-resume-delay` 2.0; `ebox-runtime-idle-prewarm-slice-size` 32 |
| Runtime prewarm | `ebox-runtime-idle-prewarm` t; `ebox-runtime-idle-prewarm-delay` 0.1; `ebox-runtime-idle-prewarm-prefix-resume-delay` 2.0; `ebox-runtime-idle-prewarm-slice-size` 32; `ebox-native-buffer-scroll` t (initial/visible-window handoff only; strict root-owner proof) |
| Predicted reflow | `ebox-runtime-idle-reflow-cache-prewarm` t; `ebox-runtime-idle-reflow-cache-prewarm-delay` 0.15 |
| Reflow GC | `ebox-reflow-cache-prewarm-gc-cons-threshold` `auto`; `ebox-reflow-cache-prewarm-gc-auto-frame-budget` 0.2; `ebox-reflow-cache-prewarm-gc-auto-min-threshold` 64 MiB; `ebox-reflow-cache-prewarm-gc-auto-max-threshold` 1 GiB; `ebox-reflow-cache-prewarm-gc-auto-initial-threshold` 512 MiB; `ebox-reflow-cache-prewarm-gc-auto-target-layouts` 24; `ebox-reflow-cache-prewarm-gc-cons-percentage` 0.1 |
| Visual verification | `ebox-visual-check-output-dir` points to the temporary directory used for optional screenshots and reports. |
@ -528,7 +552,7 @@ style-rule functions immediately below are public module-level style APIs.
| `ebox-render`, `ebox-render-to-buffer`, `ebox-display-buffer` | Pure materialization, retained mount, and display wrapper. |
| `ebox-commit`, `ebox-buffer-update-report`, `ebox-rerender-buffer-with-context` | Atomic root commit, report lookup, and viewport-context rerender. |
| `ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update` | Region mapping, logical handle lookup, and direct update. |
| `ebox-candidate-begin`, `ebox-candidate-replace`, `ebox-candidate-replace-host-ref` | One-shot logical candidate transactions. |
| `ebox-child-range`, `ebox-range-ref-present-p`, `ebox-candidate-begin`, `ebox-candidate-replace`, `ebox-candidate-replace-range-ref`, `ebox-candidate-replace-root`, `ebox-candidate-replace-host-ref` | Persistent material child segments, backend-anchor lookup, and one-shot logical candidate transactions. |
| `ebox-host-ref-bounds`, `ebox-host-ref-position` | Live host-reference bounds and first position. |
| `ebox-selector-parse`, `ebox-selector-match-node-p`, `ebox-selector-query-all`, `ebox-selector-query-buffer`, `ebox-selector-update-buffer` | ECSS selector compilation, matching, querying, and batch update. |
| `ebox-select-all`, `ebox-update-selector` | Compatibility aliases for the two buffer selector functions. |
@ -563,5 +587,5 @@ Focused targets include `make core-tests`, `make grid-tests`,
`make flex-tests`, and `make visual-check-tests`. The sibling
`ebox-playground` package owns runnable example fixtures; ETAF owns
Components, state, controls, and application behavior. Do not call private
`ebox--*` functions or edit the historical `emacs-box` checkout as part of
`ebox--*` functions or edit the historical Ebox checkout as part of
this package.

View File

@ -68,8 +68,8 @@ window再切换到渲染后的 buffer。如果窗口选择由调用方负责
(ebox-create &rest properties)
(ebox-concat node-1 node-2) ; 两个节点横向排列
(ebox-stack node-1 node-2) ; 两个节点纵向排列
(ebox-row &rest nodes) ; 多个节点横向排列
(ebox-column &rest nodes) ; 多个节点纵向排列
(ebox-row &rest nodes) ; 多个节点横向排列;单个 Range 仍保留 row parent
(ebox-column &rest nodes) ; 多个节点纵向排列;单个 Range 仍保留 column parent
(ebox-spacer &rest properties) ; 空白 box
```
@ -304,11 +304,25 @@ Candidate 是基于当前已发布 runtime 的一次性 transaction。多个稳
(ebox-commit buffer candidate))
```
如果框架的 semantic owner 与 backend anchor 分离,可在选择更宽的发布 owner
之前使用只读函数 `ebox-range-ref-present-p`。当 semantic Range 嵌套在 material
anchor 内、当前 Ebox publication 没有独立地址时,它返回 nil。
`ebox-candidate-replace-host-ref` 使用应用拥有的 `:host-ref`,不需要 runtime
node id。两个 replacement 函数都接受可选的 `old-semantic-key`
`new-semantic-key`,用于有限的 detached identity 复用。Candidate 在 commit
时 sealed不能复用若捕获的 runtime 或 buffer tick 发生变化,它也会 stale。
`ebox-candidate-replace-root` 使用私有且绑定 candidate 的 root 地址。它只接受
一个声明式节点、清除调用方 runtime identity并采用 last-wins 语义。最终 root
replacement 会吸收其前后记录的 descendant node/host-ref operation不添加
wrapper也不暴露公共 reference。
`ebox-child-range` 在 material child list 中创建非节点 segment descriptor。
其非 nil ref 在 root 内全局唯一items 直接参与 parent 的 key、selector、style
与 layout scope。`ebox-candidate-replace-range-ref` 接受 proper declarative
node list只替换该 base payloadempty payload 仍可寻址。
### Host-reference 位置
```elisp
@ -328,6 +342,13 @@ copy目标缺失或不是 Ebox runtime 时会报错。首次 mount 在第一
前没有 update report。报告包含 Ebox strategy、publication scope、TP surface
revision、物理 operation 和 reconciliation 事实。
完整签名是 `(ebox-commit BUFFER NEXT-ROOT &optional FRAMEWORK-PUBLISH
FRAMEWORK-ROLLBACK)`。buffer、TP surface 与 Ebox runtime 一致后publish 接收
report若后续 phase 失败rollback 最多一次接收同一个 report其 error/quit
会被隔离。`:framework-participant-state`、
`:framework-participant-diagnostics``:scroll-finalization-diagnostics`
是只读 outcome 字段。
当 root 依赖新的 viewport 时使用 `ebox-rerender-buffer-with-context`
```elisp
@ -414,9 +435,9 @@ text scale 和固定 `display` space它也是布局使用的公共测量原
| 分组 | 变量与默认值 |
| --- | --- |
| Render cache | `ebox-render-cache-max-entries` 2048`ebox-render-cache-max-bytes` 32 MiB`ebox-render-root-cache-max-entries` 16`ebox-render-root-cache-max-bytes` 8 MiB |
| 键盘/鼠标滚动 | `ebox-scroll-step` 1`ebox-wheel-scroll-step` 16`ebox-wheel-smooth-scroll` t`ebox-wheel-smooth-scroll-interval` 0.016`ebox-wheel-smooth-scroll-lines-per-tick` 4`ebox-wheel-smooth-scroll-target-ticks` 8 |
| 键盘/鼠标滚动 | `ebox-scroll-step` 1`ebox-wheel-scroll-step` 1`ebox-wheel-smooth-scroll` nil`ebox-wheel-smooth-scroll-interval` 0.016`ebox-wheel-smooth-scroll-lines-per-tick` 4`ebox-wheel-smooth-scroll-target-ticks` 8。键盘以 point 为 anchor滚轮以 event 位置为 anchor剩余行沿嵌套 owner 向外冒泡。交互式、内容完整且无 chrome 的根 document owner 可在 idle 时一次 materialize之后使用 Emacs 原生 window line start普通更新/resize 只预热 retained index不启动第二次 native publication嵌套/lazy/chrome owner 继续走事务化 Ebox publication。 |
| Lazy scroll | `ebox-scroll-lazy-prefix-lookahead-lines` 8`ebox-scroll-lazy-idle-prefetch-lines` 128`ebox-scroll-lazy-idle-prefetch-slice-lines` 16`ebox-scroll-lazy-idle-prefetch-delay` 0.15 |
| Runtime prewarm | `ebox-runtime-idle-prewarm` t`ebox-runtime-idle-prewarm-delay` 0.1`ebox-runtime-idle-prewarm-prefix-resume-delay` 2.0`ebox-runtime-idle-prewarm-slice-size` 32 |
| Runtime prewarm | `ebox-runtime-idle-prewarm` t`ebox-runtime-idle-prewarm-delay` 0.1`ebox-runtime-idle-prewarm-prefix-resume-delay` 2.0`ebox-runtime-idle-prewarm-slice-size` 32`ebox-native-buffer-scroll` t仅 initial/visible-window handoff严格 root-owner proof |
| 预测 reflow | `ebox-runtime-idle-reflow-cache-prewarm` t`ebox-runtime-idle-reflow-cache-prewarm-delay` 0.15 |
| Reflow GC | `ebox-reflow-cache-prewarm-gc-cons-threshold` `auto``ebox-reflow-cache-prewarm-gc-auto-frame-budget` 0.2`ebox-reflow-cache-prewarm-gc-auto-min-threshold` 64 MiB`ebox-reflow-cache-prewarm-gc-auto-max-threshold` 1 GiB`ebox-reflow-cache-prewarm-gc-auto-initial-threshold` 512 MiB`ebox-reflow-cache-prewarm-gc-auto-target-layouts` 24`ebox-reflow-cache-prewarm-gc-cons-percentage` 0.1 |
| Visual verification | `ebox-visual-check-output-dir` 指向可选 screenshot/report 使用的临时目录。 |
@ -483,7 +504,7 @@ style API。
| `ebox-render`、`ebox-render-to-buffer`、`ebox-display-buffer` | 纯 materialization、retained mount 与展示包装。 |
| `ebox-commit`、`ebox-buffer-update-report`、`ebox-rerender-buffer-with-context` | 原子根提交、报告查询和 viewport-context rerender。 |
| `ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update` | Region 映射、logical handle 查询与直接更新。 |
| `ebox-candidate-begin`、`ebox-candidate-replace`、`ebox-candidate-replace-host-ref` | 一次性 logical candidate transaction。 |
| `ebox-child-range`、`ebox-range-ref-present-p`、`ebox-candidate-begin`、`ebox-candidate-replace`、`ebox-candidate-replace-range-ref`、`ebox-candidate-replace-root`、`ebox-candidate-replace-host-ref` | 持久 material child segment、backend anchor 查询与一次性 logical candidate transaction。 |
| `ebox-host-ref-bounds`、`ebox-host-ref-position` | Host-reference 的 live bounds 与首位置。 |
| `ebox-selector-parse`、`ebox-selector-match-node-p`、`ebox-selector-query-all`、`ebox-selector-query-buffer`、`ebox-selector-update-buffer` | ECSS selector 编译、匹配、查询与批量更新。 |
| `ebox-select-all`、`ebox-update-selector` | 两个 buffer selector 函数的兼容别名。 |
@ -516,4 +537,4 @@ make native-rust-tests
`make selector-tests`、`make dsl-tests`、`make flex-tests` 和
`make visual-check-tests`。可运行的 `.ebox` 示例由同级 `ebox-playground`
包负责ETAF 负责 Component、state、control 和应用行为。本包不要调用
私有 `ebox--*` 函数,也不要在 Ebox 任务中编辑历史 `emacs-box` checkout。
私有 `ebox--*` 函数,也不要在 Ebox 任务中编辑历史 Ebox checkout。

View File

@ -147,7 +147,7 @@ Give a box `:overflow 'scroll` (the default) and a finite `:height` to create a
:content (mapconcat #'identity lines "\n"))
```
`ebox-scroll-down`, `ebox-scroll-up`, `ebox-scroll-page-down`, and `ebox-scroll-page-up` operate on the innermost Ebox scroll region at point and fall back to Emacs scrolling when no Ebox region can consume the command. `ebox-wheel-scroll-down` and `ebox-wheel-scroll-up` consume mouse events for Ebox regions and otherwise delegate to `mwheel-scroll`. `ebox-buffer-mode` installs `ebox-scroll-map` locally; `ebox-render-to-buffer` enables it on its returned buffer. `ebox-scroll-state` exposes read-only scroll facts for a numeric region id; use scroll commands or `ebox-region-update` with `:scroll-offset` to change position.
`ebox-scroll-down`, `ebox-scroll-up`, `ebox-scroll-page-down`, and `ebox-scroll-page-up` start at the innermost scroll owner under point. Keyboard line intent is allocated one line at a time through semantic scroll ancestors; any residual continues to the parent and then ordinary Emacs buffer scrolling. `ebox-wheel-scroll-down` and `ebox-wheel-scroll-up` anchor at the mouse event position (not point) and use the same inner-to-outer residual rule. When no Ebox region can consume the request they delegate to normal Emacs scrolling. `ebox-buffer-mode` installs `ebox-scroll-map` locally; `ebox-render-to-buffer` enables it on its returned buffer. `ebox-scroll-state` exposes read-only scroll facts for a numeric region id; use scroll commands or `ebox-region-update` with `:scroll-offset` to change position.
Viewport-dependent values use `(viewport)` and `(viewport-height)`. Rerender a mounted buffer with an explicit context:
@ -155,7 +155,7 @@ Viewport-dependent values use `(viewport)` and `(viewport-height)`. Rerender a m
(ebox-rerender-buffer-with-context (get-buffer "*Ebox Demo*") 800 30)
```
The call preserves node and region identity. The [API reference](ebox-api-reference.en.md#8-scrolling-and-viewport-state) lists lazy prefix, idle prefetch, cache, and scroll customization variables.
The call preserves node and region identity. In an interactive preview, a complete chrome-free root document owner is idle-materialized once and then follows Emacs's native line-oriented window scrolling; nested or still-lazy owners keep the transactional retained-window path. The [API reference](ebox-api-reference.en.md#8-scrolling-and-viewport-state) lists lazy prefix, idle prefetch, cache, and scroll customization variables.
## 9. Standalone `.ebox` files

View File

@ -147,7 +147,7 @@ Selector 查询已渲染的树并返回公共匹配记录,但不拥有应用
```
`ebox-scroll-down`、`ebox-scroll-up`、`ebox-scroll-page-down` 和
`ebox-scroll-page-up` 操作 point 所在的最内层 Ebox scroll region没有可消费命令的 Ebox region 时回退到 Emacs scrolling。`ebox-wheel-scroll-down` 与 `ebox-wheel-scroll-up` 为 Ebox region 消费 mouse event否则委托 `mwheel-scroll`。`ebox-buffer-mode` 本地安装 `ebox-scroll-map``ebox-render-to-buffer` 会在返回的 buffer 上启用它。`ebox-scroll-state` 为数字 region id 提供只读 scroll facts使用滚动命令或 `ebox-region-update``:scroll-offset` 改变位置。
`ebox-scroll-page-up`、`ebox-scroll-down` 等键盘命令从 point 所在的最内层 scroll owner 开始,默认按一行意图分配;内层不足的剩余量继续交给语义父盒子,最后才回退到普通 Emacs buffer scrolling。`ebox-wheel-scroll-down` 与 `ebox-wheel-scroll-up` 以 mouse event 位置为 anchor不使用 point采用同样的内到外 residual 路由;没有可消费的 Ebox region 时才委托普通 Emacs scrolling。`ebox-buffer-mode` 本地安装 `ebox-scroll-map``ebox-render-to-buffer` 会在返回的 buffer 上启用它。`ebox-scroll-state` 为数字 region id 提供只读 scroll facts使用滚动命令或 `ebox-region-update``:scroll-offset` 改变位置。
Viewport 值使用 `(viewport)``(viewport-height)`。mounted buffer 要用显式 context 重排:
@ -155,7 +155,7 @@ Viewport 值使用 `(viewport)` 与 `(viewport-height)`。mounted buffer 要用
(ebox-rerender-buffer-with-context (get-buffer "*Ebox Demo*") 800 30)
```
该调用保留 node 与 region identity。[公共 API 参考](ebox-api-reference.zh.md)列出了 lazy prefix、idle prefetch、cache 和滚动配置变量。
该调用保留 node 与 region identity。交互式预览中,内容完整且无 chrome 的根 document owner 会在 idle 时一次 materialize随后采用 Emacs 原生逐行 window scrolling嵌套或仍处于 lazy 状态的 owner 继续使用事务化 retained-window 路径。[公共 API 参考](ebox-api-reference.zh.md)列出了 lazy prefix、idle prefetch、cache 和滚动配置变量。
## 9. 独立 `.ebox` 文件

View File

@ -17,6 +17,8 @@
"ebox-render-context" (property value))
(declare-function ebox--register-render-owned-face-values
"ebox-render-context" (source rendered))
(declare-function ebox--add-render-face!
"ebox-render-context" (string start end face &optional append))
(declare-function ebox--string-repeat-lines
"ebox" (string count))
(declare-function ebox-lines-join
@ -240,7 +242,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
"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--add-render-face! copy 0 (length copy) face t)
(ebox--register-render-owned-face-values string copy))
string))
@ -259,9 +261,8 @@ buffer-facing paint properties. Layout-only properties never pass through."
(display
(ebox--register-render-owned-text-value
'display `(space :width (,pixel-width))))
(line (propertize " "
'face face
'display display)))
(line (propertize " " 'display display)))
(ebox--add-render-face! line 0 (length line) face)
(ebox--register-render-owned-text-value 'face face)
(ebox--string-repeat-lines line height))))
@ -270,7 +271,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
(let* ((source string)
(string (copy-sequence source))
(length (length string)))
(add-face-text-property 0 length `(:background ,bgcolor) t string)
(ebox--add-render-face! string 0 length `(:background ,bgcolor) t)
(ebox--register-render-owned-face-values source string)))
(defun ebox--propertize-color (string color)
@ -278,12 +279,12 @@ buffer-facing paint properties. Layout-only properties never pass through."
(let* ((source string)
(string (copy-sequence source))
(length (length string)))
(add-face-text-property
0 length
(ebox--add-render-face!
string 0 length
(if (eq color 'ebox/default-foreground)
'(:inherit default)
`(:foreground ,color))
t string)
t)
(ebox--register-render-owned-face-values source string)))
(defun ebox--propertize-colors (string color bgcolor)
@ -292,33 +293,33 @@ buffer-facing paint properties. Layout-only properties never pass through."
(string (copy-sequence source))
(length (length string)))
(when color
(add-face-text-property
0 length
(ebox--add-render-face!
string 0 length
(if (eq color 'ebox/default-foreground)
'(:inherit default)
`(:foreground ,color))
t string))
t))
(when bgcolor
(add-face-text-property
0 length `(:background ,bgcolor) t string))
(ebox--add-render-face!
string 0 length `(:background ,bgcolor) t))
(ebox--register-render-owned-face-values source string)))
(defun ebox--propertize-overline (string &optional color)
"Apply overline to STRING with optional COLOR."
(let* ((source string)
(string (copy-sequence source)))
(add-face-text-property
0 (length string) `(:overline ,(or color t)) t string)
(ebox--add-render-face!
string 0 (length string) `(:overline ,(or color t)) t)
(ebox--register-render-owned-face-values source string)))
(defun ebox--propertize-underline (string &optional color)
"Apply underline to STRING with optional COLOR."
(let* ((source string)
(string (copy-sequence source)))
(add-face-text-property
0 (length string)
(ebox--add-render-face!
string 0 (length string)
`(:underline (:position t ,@(when color `(:color ,color))))
t string)
t)
(ebox--register-render-owned-face-values source string)))
(defun ebox--propertize-region (string property region-id)

471
ebox-child-range.el Normal file
View File

@ -0,0 +1,471 @@
;;; ebox-child-range.el --- Persistent child sequence core -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Immutable weighted segment and key tries for future child Range integration.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(cl-defstruct (ebox-child-range--metrics
(:constructor ebox-child-range--make-metrics))
(segment-visits 0) (segment-copies 0) (ref-index-visits 0)
(ref-index-copies 0) (key-visits 0) (key-copies 0)
(collision-visits 0) (collision-copies 0)
(old-affected-payload-visits 0) (new-payload-validations 0)
(new-payload-copies 0) (new-payload-visits 0)
(unaffected-payload-visits 0) (unaffected-payload-validations 0)
(unaffected-payload-copies 0))
(cl-defstruct (ebox-child-range--segment
(:constructor ebox-child-range--make-segment))
ref payload)
(cl-defstruct (ebox-child-range--segment-node
(:constructor ebox-child-range--make-segment-node))
children value weight segment-count)
(cl-defstruct (ebox-child-range--hash-node
(:constructor ebox-child-range--make-hash-node))
children bucket)
(cl-defstruct (ebox-child-range--sequence
(:constructor ebox-child-range--make-sequence))
root height count ref-index key-root hash-function)
(cl-defstruct (ebox-child-range--descriptor
(:constructor ebox-child-range--make-descriptor))
ref items)
(defun ebox-child-range--metric-add (metrics slot &optional amount)
"Increment METRICS SLOT by AMOUNT, defaulting to one."
(setf (cl-struct-slot-value 'ebox-child-range--metrics slot metrics)
(+ (or (cl-struct-slot-value 'ebox-child-range--metrics slot metrics) 0)
(or amount 1))))
(defun ebox-child-range--height (count)
"Return the base-32 trie height needed for COUNT segments."
(let ((height 0) (capacity 1))
(while (> count capacity)
(setq height (1+ height) capacity (* capacity 32)))
height))
(defun ebox-child-range--digit (index level)
"Return INDEX's five-bit digit at LEVEL."
(logand 31 (ash index (* -5 level))))
(defun ebox-child-range--segment-insert (node height index value)
"Persistently insert VALUE at INDEX below NODE of HEIGHT."
(if (= height 0)
(ebox-child-range--make-segment-node
:value value :weight (length (ebox-child-range--segment-payload value))
:segment-count 1)
(let* ((slot (ebox-child-range--digit index (1- height)))
(children (copy-sequence
(or (and node (ebox-child-range--segment-node-children node))
(make-vector 32 nil))))
(old (aref children slot))
(new (if (= height 1)
(ebox-child-range--make-segment-node
:value value :weight (length (ebox-child-range--segment-payload value))
:segment-count 1)
(ebox-child-range--segment-insert old (1- height) index value))))
(aset children slot new)
(ebox-child-range--make-segment-node
:children children
:weight (cl-loop for child across children when child
sum (ebox-child-range--segment-node-weight child))
:segment-count (cl-loop for child across children when child
sum (ebox-child-range--segment-node-segment-count child))))))
(defun ebox-child-range--segment-at (sequence index)
"Return SEQUENCE segment at INDEX."
(unless (and (integerp index) (<= 0 index)
(< index (ebox-child-range--sequence-count sequence)))
(signal 'args-out-of-range (list index)))
(let ((node (ebox-child-range--sequence-root sequence))
(height (ebox-child-range--sequence-height sequence)))
(while (> height 0)
(setq node (aref (ebox-child-range--segment-node-children node)
(ebox-child-range--digit index (1- height)))
height (1- height)))
(ebox-child-range--segment-node-value node)))
(defun ebox-child-range--segment-replace (node height index value metrics)
"Return `(NEW-NODE . OLD-SEGMENT)' below NODE of HEIGHT at INDEX.
VALUE is the replacement segment and METRICS records all path work."
(ebox-child-range--metric-add metrics 'segment-visits)
(ebox-child-range--metric-add metrics 'segment-copies)
(if (= height 0)
(cons (ebox-child-range--make-segment-node
:value value
:weight (length (ebox-child-range--segment-payload value))
:segment-count 1)
(ebox-child-range--segment-node-value node))
(let* ((slot (ebox-child-range--digit index (1- height)))
(children (copy-sequence
(ebox-child-range--segment-node-children node)))
(old (aref children slot))
result new old-segment)
(if (= height 1)
(progn
(ebox-child-range--metric-add metrics 'segment-visits)
(ebox-child-range--metric-add metrics 'segment-copies)
(setq old-segment (ebox-child-range--segment-node-value old)
new (ebox-child-range--make-segment-node
:value value
:weight (length (ebox-child-range--segment-payload value))
:segment-count 1)))
(setq result (ebox-child-range--segment-replace
old (1- height) index value metrics)
new (car result)
old-segment (cdr result)))
(aset children slot new)
(cons
(ebox-child-range--make-segment-node
:children children
:weight (cl-loop for child across children when child
sum (ebox-child-range--segment-node-weight child))
:segment-count (ebox-child-range--segment-node-segment-count node))
old-segment))))
(defun ebox-child-range--stable-hash (key)
"Return KEY's stable unsigned 32-bit hash."
(logand #xffffffff (sxhash-equal key)))
(defun ebox-child-range--bucket-find (bucket key metrics)
"Return KEY entry in collision BUCKET and count METRICS comparisons."
(let (found)
(while bucket
(ebox-child-range--metric-add metrics 'collision-visits)
(when (equal key (caar bucket)) (setq found (car bucket)))
(setq bucket (cdr bucket)))
found))
(defun ebox-child-range--hash-change
(node key value hash depth remove-p metrics)
"Persistently change KEY in sparse hash NODE using HASH at DEPTH."
(if (= depth 7)
(let* ((source (and node (ebox-child-range--hash-node-bucket node)))
(found (ebox-child-range--bucket-find source key metrics))
(bucket (copy-sequence source)))
(ebox-child-range--metric-add metrics 'collision-copies
(length source))
(cond
(remove-p
(ebox-child-range--make-hash-node
:bucket (cl-delete key bucket :key #'car :test #'equal)))
(found (error "Ebox child key is not unique: %S" key))
(t (ebox-child-range--make-hash-node
:bucket (cons (cons key value) bucket)))))
(ebox-child-range--metric-add metrics 'key-visits)
(ebox-child-range--metric-add metrics 'key-copies)
(let* ((slot (logand 31 (ash hash (* -5 depth))))
(children (copy-sequence (and node
(ebox-child-range--hash-node-children
node))))
(old (cdr (assq slot children)))
(new (ebox-child-range--hash-change
old key value hash (1+ depth) remove-p metrics)))
(setq children (cons (cons slot new) (assq-delete-all slot children)))
(ebox-child-range--make-hash-node :children children))))
(defun ebox-child-range--hash-lookup (root key hash)
"Look up KEY with HASH in ROOT."
(let ((node root) (depth 0))
(while (< depth 7)
(setq node (and node
(cdr (assq (logand 31 (ash hash (* -5 depth)))
(ebox-child-range--hash-node-children node))))
depth (1+ depth)))
(cdr (assoc key (and node (ebox-child-range--hash-node-bucket node))
#'equal))))
(defun ebox-child-range--item-copy (item metrics)
"Validate and copy declarative ITEM while recording METRICS."
(when (ebox-child-range--descriptor-p item)
(error "Nested child Range descriptors are reserved"))
(unless (and (listp item) (not (stringp item)))
(error "Ebox child Range item must be one declarative node"))
(ebox-child-range--metric-add metrics 'new-payload-validations)
(ebox-child-range--metric-add metrics 'new-payload-visits)
(ebox-child-range--metric-add metrics 'new-payload-copies)
(copy-sequence item))
(defun ebox-child-range--descriptor-create (ref items)
"Return a reserved Range descriptor for non-nil REF and list ITEMS."
(unless ref (error "Ebox child Range ref must be non-nil"))
(unless (proper-list-p items)
(signal 'wrong-type-argument (list 'proper-list-p items)))
(ebox-child-range--make-descriptor :ref ref :items items))
(defun ebox-child-range--build (segments &optional hash-function)
"Build an immutable child sequence from SEGMENTS.
Each entry is `(REF . ITEMS)'; nil REF denotes one static child item.
HASH-FUNCTION optionally supplies the stable key hash."
(unless (proper-list-p segments)
(signal 'wrong-type-argument (list 'proper-list-p segments)))
(let* ((count (length segments))
(height (ebox-child-range--height count))
(hash-function (or hash-function #'ebox-child-range--stable-hash))
(metrics (ebox-child-range--make-metrics))
root key-root (refs (make-hash-table :test #'equal)) (index 0))
(when (zerop count) (error "Ebox child sequence requires a segment"))
(dolist (entry segments)
(let* ((ref (car entry))
(items (cdr entry)))
(unless (proper-list-p items)
(signal 'wrong-type-argument (list 'proper-list-p items)))
(when (and (null ref) (/= (length items) 1))
(error "Ebox static child segment requires exactly one item"))
(when (and ref (gethash ref refs))
(error "Ebox child Range ref is not unique: %S" ref))
(when ref (puthash ref index refs))
(let ((payload (vconcat
(mapcar (lambda (item)
(ebox-child-range--item-copy item metrics))
items))))
(setq root (ebox-child-range--segment-insert
root height index
(ebox-child-range--make-segment :ref ref :payload payload)))
(dotimes (offset (length payload))
(let* ((item (aref payload offset)) (key (plist-get item :key)))
(when key
(setq key-root
(ebox-child-range--hash-change
key-root key (cons index offset)
(funcall hash-function key) 0 nil metrics)))))))
(setq index (1+ index)))
(ebox-child-range--make-sequence
:root root :height height :count count :ref-index refs
:key-root key-root :hash-function hash-function)))
(defun ebox-child-range--replace (sequence ref items)
"Return `(NEW-SEQUENCE . METRICS)' for SEQUENCE after replacing REF ITEMS."
(ebox-child-range--replace-many sequence (list (cons ref items))))
(defun ebox-child-range--replace-many (sequence replacements)
"Replace RANGE REPLACEMENTS in SEQUENCE with one two-phase transaction."
(let ((metrics (ebox-child-range--make-metrics))
(missing (make-symbol "missing-range-ref"))
(seen (make-hash-table :test #'equal)) records)
(unless (proper-list-p replacements)
(signal 'wrong-type-argument (list 'proper-list-p replacements)))
(dolist (entry replacements)
(unless (proper-list-p (cdr entry))
(signal 'wrong-type-argument (list 'proper-list-p (cdr entry))))
(when (gethash (car entry) seen)
(error "Ebox child Range replacement ref repeats: %S" (car entry)))
(puthash (car entry) t seen)
(ebox-child-range--metric-add metrics 'ref-index-visits)
(let ((index (gethash (car entry)
(ebox-child-range--sequence-ref-index sequence)
missing)))
(when (eq index missing)
(error "Ebox child Range ref does not exist: %S" (car entry)))
(push (list (car entry) index
(vconcat (mapcar
(lambda (item)
(ebox-child-range--item-copy item metrics))
(cdr entry))))
records)))
(setq records (nreverse records))
(let ((key-root (ebox-child-range--sequence-key-root sequence))
(hash-function (ebox-child-range--sequence-hash-function sequence))
(root (ebox-child-range--sequence-root sequence)))
;; Each affected ref performs exactly one measured segment path copy;
;; the returned old leaf supplies phase-one key removal.
(setq records
(mapcar
(lambda (record)
(let* ((result
(ebox-child-range--segment-replace
root (ebox-child-range--sequence-height sequence)
(nth 1 record)
(ebox-child-range--make-segment
:ref (car record) :payload (nth 2 record))
metrics)))
(setq root (car result))
(list (car record) (nth 1 record) (cdr result)
(nth 2 record))))
records))
(dolist (record records)
(let ((payload (ebox-child-range--segment-payload (nth 2 record))))
(dotimes (offset (length payload))
(ebox-child-range--metric-add metrics 'old-affected-payload-visits)
(when-let ((key (plist-get (aref payload offset) :key)))
(setq key-root
(ebox-child-range--hash-change
key-root key nil (funcall hash-function key) 0 t metrics))))))
(dolist (record records)
(let ((index (nth 1 record)) (payload (nth 3 record)))
(dotimes (offset (length payload))
(when-let ((key (plist-get (aref payload offset) :key)))
(setq key-root
(ebox-child-range--hash-change
key-root key (cons index offset)
(funcall hash-function key) 0 nil metrics))))))
(cons (ebox-child-range--make-sequence
:root root :height (ebox-child-range--sequence-height sequence)
:count (ebox-child-range--sequence-count sequence)
:ref-index (ebox-child-range--sequence-ref-index sequence)
:key-root key-root :hash-function hash-function)
metrics))))
(defun ebox-child-range--replace-item-at
(sequence segment-index offset old-item new-item)
"Persistently replace OLD-ITEM by NEW-ITEM in SEQUENCE.
SEGMENT-INDEX and OFFSET identify its exact material-child location."
(let* ((segment (ebox-child-range--segment-at sequence segment-index))
(payload (copy-sequence (ebox-child-range--segment-payload segment)))
(key-root (ebox-child-range--sequence-key-root sequence))
(hash-function (ebox-child-range--sequence-hash-function sequence))
(metrics (ebox-child-range--make-metrics))
(old-key (plist-get old-item :key))
(new-key (plist-get new-item :key)))
(unless (eq (aref payload offset) old-item)
(error "Ebox child sequence location is stale"))
(when old-key
(setq key-root (ebox-child-range--hash-change
key-root old-key nil (funcall hash-function old-key)
0 t metrics)))
(when new-key
(setq key-root (ebox-child-range--hash-change
key-root new-key (cons segment-index offset)
(funcall hash-function new-key) 0 nil metrics)))
(aset payload offset new-item)
(let ((result
(ebox-child-range--segment-replace
(ebox-child-range--sequence-root sequence)
(ebox-child-range--sequence-height sequence) segment-index
(ebox-child-range--make-segment
:ref (ebox-child-range--segment-ref segment) :payload payload)
metrics)))
(ebox-child-range--make-sequence
:root (car result) :height (ebox-child-range--sequence-height sequence)
:count (ebox-child-range--sequence-count sequence)
:ref-index (ebox-child-range--sequence-ref-index sequence)
:key-root key-root :hash-function hash-function))))
(defun ebox-child-range--prefix-weight (sequence index)
"Return flattened item weight before segment INDEX in SEQUENCE."
(let ((node (ebox-child-range--sequence-root sequence))
(height (ebox-child-range--sequence-height sequence))
(weight 0))
(while (> height 0)
(let* ((children (ebox-child-range--segment-node-children node))
(slot (ebox-child-range--digit index (1- height))))
(dotimes (cursor slot)
(when-let ((child (aref children cursor)))
(setq weight (+ weight
(ebox-child-range--segment-node-weight child)))))
(setq node (aref children slot)
height (1- height))))
weight))
(defun ebox-child-range--rank (sequence index)
"Return the flattened child rank of segment INDEX in SEQUENCE."
(ebox-child-range--prefix-weight sequence index))
(defun ebox-child-range--lookup-ref (sequence ref)
"Return SEQUENCE's segment addressed by non-nil REF."
(let ((missing (make-symbol "missing-range-ref")))
(let ((index (gethash ref (ebox-child-range--sequence-ref-index sequence)
missing)))
(unless (eq index missing)
(ebox-child-range--segment-at sequence index)))))
(defun ebox-child-range--range-records (sequence)
"Return stable `(REF INDEX)' records for SEQUENCE ranges."
(let (records)
(dotimes (index (ebox-child-range--sequence-count sequence))
(let ((segment (ebox-child-range--segment-at sequence index)))
(when-let ((ref (ebox-child-range--segment-ref segment)))
(push (list ref index) records))))
(nreverse records)))
(defun ebox-child-range--segment-memory (sequence)
"Return `(NODES . EDGES)' used by SEQUENCE's fixed-vector segment trie."
(let ((nodes 0) (edges 0))
(cl-labels ((walk (node)
(setq nodes (1+ nodes))
(when-let ((children
(ebox-child-range--segment-node-children node)))
(dotimes (slot 32)
(when-let ((child (aref children slot)))
(setq edges (1+ edges))
(walk child))))))
(walk (ebox-child-range--sequence-root sequence)))
(cons nodes edges)))
(defun ebox-child-range--segment-vector-memory (sequence)
"Return `(VECTORS . SLOTS)' allocated by SEQUENCE's segment trie."
(let ((vectors 0))
(cl-labels ((walk (node)
(when-let ((children
(ebox-child-range--segment-node-children node)))
(setq vectors (1+ vectors))
(dotimes (slot 32)
(when-let ((child (aref children slot))) (walk child))))))
(walk (ebox-child-range--sequence-root sequence)))
(cons vectors (* 32 vectors))))
(defun ebox-child-range--hash-memory (root)
"Return `(NODES . EDGES)' for sparse persistent hash ROOT."
(let ((nodes 0) (edges 0))
(cl-labels ((walk (node)
(when node
(setq nodes (1+ nodes))
(dolist (entry (ebox-child-range--hash-node-children node))
(setq edges (1+ edges))
(walk (cdr entry))))))
(walk root))
(cons nodes edges)))
(defun ebox-child-range--metrics-plist (metrics)
"Return a read-only plist snapshot of METRICS."
(list :segment-visits (ebox-child-range--metrics-segment-visits metrics)
:segment-copies (ebox-child-range--metrics-segment-copies metrics)
:ref-index-visits (ebox-child-range--metrics-ref-index-visits metrics)
:ref-index-copies (ebox-child-range--metrics-ref-index-copies metrics)
:key-visits (ebox-child-range--metrics-key-visits metrics)
:key-copies (ebox-child-range--metrics-key-copies metrics)
:collision-visits (ebox-child-range--metrics-collision-visits metrics)
:collision-copies (ebox-child-range--metrics-collision-copies metrics)
:old-affected-payload-visits
(ebox-child-range--metrics-old-affected-payload-visits metrics)
:new-payload-validations
(ebox-child-range--metrics-new-payload-validations metrics)
:new-payload-copies (ebox-child-range--metrics-new-payload-copies metrics)
:new-payload-visits (ebox-child-range--metrics-new-payload-visits metrics)
:unaffected-payload-visits
(ebox-child-range--metrics-unaffected-payload-visits metrics)
:unaffected-payload-validations
(ebox-child-range--metrics-unaffected-payload-validations metrics)
:unaffected-payload-copies
(ebox-child-range--metrics-unaffected-payload-copies metrics)))
(defun ebox-child-range--fold (sequence function initial)
"Fold SEQUENCE payloads in stable order with FUNCTION and INITIAL."
(let ((value initial)
(index 0))
(while (< index (ebox-child-range--sequence-count sequence))
(let ((payload (ebox-child-range--segment-payload
(ebox-child-range--segment-at sequence index))))
(dotimes (offset (length payload))
(setq value (funcall function value (aref payload offset)))))
(setq index (1+ index)))
value))
(defun ebox-child-range--flatten (sequence)
"Return SEQUENCE's explicit debug-only flat item list."
(nreverse
(ebox-child-range--fold
sequence (lambda (items item) (cons item items)) nil)))
(provide 'ebox-child-range)
;;; ebox-child-range.el ends here

View File

@ -123,7 +123,13 @@ The first value is string content. The second value is a lazy child node."
(let* ((split (ebox--build-split-attrs items))
(props (car split))
(children (cdr split))
(layout (apply constructor (mapcar #'ebox-dsl-build (delq nil children)))))
(layout
(apply constructor
(mapcar (lambda (child)
(if (ebox-child-range--descriptor-p child)
child
(ebox-dsl-build child)))
(delq nil children)))))
(ebox--build-wrap-layout tag props layout)))
(defun ebox--build-flex (items)

View File

@ -485,7 +485,7 @@ grapheme."
(dolist (child (ebox--layout-children node))
(ebox--flex-recache-source-boxes child)))
('flex
(dolist (child (plist-get node :children))
(dolist (child (ebox-tree-layout-children node))
(ebox--flex-recache-source-boxes child)))
('flex-item
(ebox--flex-recache-source-boxes (plist-get node :node)))))))
@ -1468,7 +1468,7 @@ its child."
(plist-get node :raw-props)
(plist-get node :box)))
(axis (ebox--flex-axis props))
(children (plist-get node :children))
(children (ebox-tree-layout-children node))
(main-size (if (eq axis 'row)
(ebox--flex-horizontal-value (plist-get props :width))
(ebox--flex-line-value (plist-get props :height))))
@ -1512,7 +1512,7 @@ assembles already-rendered items."
(plist-get node :raw-props)
(plist-get node :box)))
(axis (ebox--flex-axis props))
(children (plist-get node :children))
(children (ebox-tree-layout-children node))
(main-size (ebox--flex-main-value
axis (plist-get props
(if (eq axis 'row)
@ -2186,7 +2186,7 @@ Return nil when the flex container needs the normal full renderer."
(plist-get node :raw-props)
(plist-get node :box)))
(axis (ebox--flex-axis props))
(children (plist-get node :children))
(children (ebox-tree-layout-children node))
(main-size (if (eq axis 'row)
(ebox--flex-horizontal-value
(plist-get props :width))
@ -2333,7 +2333,7 @@ known-row render is used only if the new item footprint is not smaller."
(memq wrap '(wrap nowrap))
(null (ebox--flex-line-value (plist-get props :height)))
(not (ebox--flex-reverse-p props)))
(let* ((children (plist-get node :children))
(let* ((children (ebox-tree-layout-children node))
(inline-viewport (ebox--flex-inline-viewport
axis main-size cross-size))
(row-gap (or (ebox--flex-line-value
@ -2384,7 +2384,7 @@ Return nil when NODE's flex configuration needs a full rerender."
(memq wrap '(wrap nowrap))
main-size
(null (ebox--flex-line-value (plist-get props :height))))
(let* ((children (plist-get node :children))
(let* ((children (ebox-tree-layout-children node))
(cross-size nil)
(inline-viewport (ebox--flex-inline-viewport
axis main-size cross-size))

View File

@ -446,7 +446,10 @@ Supported properties are `:grid-column', `:grid-row',
(defun ebox-grid--auto-width-node-p (node)
"Return non-nil when NODE derives its width from the active viewport."
(let ((width (plist-get node :width)))
(or (null width) (eq width 'auto))))
;; `stretch' and `contain' are containing-block widths. Treating them
;; as intrinsic leaves fractional Grid tracks empty and makes composite
;; cards hug their natural width instead of filling the assigned track.
(or (null width) (memq width '(auto stretch contain)))))
(defun ebox-grid--entry-source (entry rendered width props)
"Return ENTRY's content rendered within WIDTH when it is auto-sized.
@ -610,7 +613,7 @@ size for start/center/end alignment unless it would overflow its track."
(defun ebox--render-grid (node)
"Render GRID NODE to a propertized string."
(let* ((props (plist-get node :raw-props))
(children (plist-get node :children))
(children (ebox-tree-layout-children node))
(columns (ebox-grid--normalize-tracks
(plist-get props :grid-template-columns) 'columns))
(rows (ebox-grid--normalize-tracks

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,9 @@ must not stretch their own outer width to that containing block.")
(defvar ebox--flat-preformatted-box-fast-path-disabled nil
"Non-nil disables one-pass preformatted wrapper rendering for diagnostics.")
(declare-function ebox--strip-paint-origins!
"ebox-render-context" (string))
(defun ebox--record-rendered-uniform-width (rendered pixel-width)
"Record that every line of RENDERED is exactly PIXEL-WIDTH wide."
(when (and (stringp rendered) (numberp pixel-width))
@ -203,18 +206,40 @@ and its formatted content exactly fills the used height."
(ebox--format-content-string box content)))
(lines (ebox-string-lines formatted))
(text-height (length lines))
(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)))
(content-height (ebox--content-height box text-height))
(content-pixel (ebox--content-pixel box))
(lines-fit-p
(ebox--preformatted-content-lines-fit-p lines content-pixel))
(underfilled-simple-scroll-p
(and lines-fit-p
(< text-height content-height)
(eq (ebox-get box :overflow) 'scroll)
(= (ebox-get box :padding-left-pixel) 0)
(= (ebox-get box :padding-right-pixel) 0)
(= (floor (ebox-get box :padding-top-height)) 0)
(= (floor (ebox-get box :padding-bottom-height)) 0)
(= (ebox-get box :border-left-pixel) 0)
(= (ebox-get box :border-right-pixel) 0)
(not (ebox-get box :border-top-p))
(not (ebox-get box :border-bottom-p)))))
(when (and lines-fit-p
(or (= text-height content-height)
underfilled-simple-scroll-p))
(let* ((region-id (or ebox--render-region-id
(ebox--ensure-region-id box)))
(content-pixel (ebox--content-pixel box))
(padding-line-filler (ebox-pixel-space content-pixel))
(rendered
(ebox-lines-join
(ebox--window-render-flat-wrapper-chunk-lines
box lines 0 region-id padding-line-filler t t))))
(if underfilled-simple-scroll-p
(ebox-lines-join
(ebox--render-underfilled-simple-scroll-lines
lines region-id content-height padding-line-filler
(ebox-get box :color) (ebox-get box :bgcolor)))
(ebox-lines-join
(ebox--window-render-flat-wrapper-chunk-lines
box lines 0 region-id padding-line-filler t t)))))
(when underfilled-simple-scroll-p
(ebox--apply-surface-properties
rendered (ebox-get box :surface-properties)))
(puthash region-id box ebox--region-box-table)
(unless ebox--intrinsic-layout-measurement
(ebox--scroll-clear-state region-id))
@ -227,6 +252,15 @@ and its formatted content exactly fills the used height."
(defvar ebox--scroll-window-render-result nil
"Dynamic metadata for the current box's lazy scroll-window render.")
(defvar ebox--scroll-window-cached-state nil
"Dynamic staged scroll state used by a bounded cached-window projection.")
(defvar ebox--scroll-window-cached-rendered-lines-p nil
"Non-nil when cached scroll lines already carry wrapper ownership.")
(defvar ebox--scroll-window-skip-state-rebuild-p nil
"Non-nil when a scroll projection already staged its derived line indexes.")
(defvar ebox--scroll-rendered-content-lines-private-p nil
"Non-nil when scroll content line inputs may receive ownership in place.")
@ -260,6 +294,8 @@ and its formatted content exactly fills the used height."
(source preserve-identities-p))
(declare-function ebox-surface--inline-inheritance-required-p "ebox-surface"
(root))
(declare-function ebox--add-render-face!
"ebox-render-context" (string start end face &optional append))
(defun ebox--register-window-line-renderer (type function)
"Register FUNCTION as TYPE's lazy window-line renderer.
@ -389,12 +425,12 @@ START-INDEX is the first source line index represented by LINES."
;; `ebox-string-lines' made these source lines private, and blank
;; normalization above creates a fresh filler. Apply the same face
;; properties in place instead of copying every viewport-wide line.
(when color
(add-face-text-property
0 (length line) `(:foreground ,color) t line))
(when color
(ebox--add-render-face!
line 0 (length line) `(:foreground ,color) t))
(when bgcolor
(add-face-text-property
0 (length line) `(:background ,bgcolor) t line))
(ebox--add-render-face!
line 0 (length line) `(:background ,bgcolor) t))
line))
(cl-loop for line in lines
for index from 0
@ -831,7 +867,9 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(when (and content-viewport
(not (eq (and (listp node) (plist-get node :ebox-type))
'box))
(not ebox--intrinsic-layout-measurement))
(not ebox--intrinsic-layout-measurement)
(equal (gethash content ebox--rendered-uniform-width-table)
content-viewport))
(plist-put box :ebox-content-width-exact-p t))
content))
@ -1210,10 +1248,16 @@ Internal implementation of `ebox-render' for box nodes."
(= border-left-pixel 0)
(= border-right-pixel 0)
(eq (ebox-get box :vertical-align) 'top))
(let ((ebox--scroll-rendered-content-lines-private-p
private-formatted-lines-p))
(ebox--scroll-rendered-content-lines
box formatted-lines region-id 0))))
(if (plist-get scroll-window-result
:cached-rendered-lines-p)
;; The staged producer already applied content/owner
;; properties. Re-running the generic ownership pass here
;; was the dominant grid-scroll stall.
formatted-lines
(let ((ebox--scroll-rendered-content-lines-private-p
private-formatted-lines-p))
(ebox--scroll-rendered-content-lines
box formatted-lines region-id 0)))))
(simple-scroll-rendered-lines
(or underfilled-simple-scroll-lines
@ -1315,7 +1359,8 @@ Internal implementation of `ebox-render' for box nodes."
;; Intrinsic measurement must leave published scroll state alone: its
;; placeholder content always looks fully visible and would clear the
;; live lazy producer state.
(unless ebox--intrinsic-layout-measurement
(unless (or ebox--intrinsic-layout-measurement
ebox--scroll-window-skip-state-rebuild-p)
(if (and (eq overflow 'scroll)
(> text-height content-height))
(ebox--scroll-set-state
@ -1520,21 +1565,25 @@ region/node identities and render side tables for this one materialization."
"Render layout NODE to a multi-line string through a TP materialization.
NODE accepts every layout node supported by `ebox--render-layout'. The
materialized surface is ephemeral and creates no live buffer state."
(if (or ebox--surface-materialization-active
ebox--render-runtime-revision)
(ebox--render-layout node)
(require 'ebox-surface)
(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))))))
(let ((rendered
(if (or ebox--surface-materialization-active
ebox--render-runtime-revision)
(ebox--render-layout node)
(require 'ebox-surface)
(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)))))))
(unless ebox--paint-origin-capture-p
(ebox--strip-paint-origins! rendered))
rendered))
(defun ebox--width-pad (string extra-pixels)
"Append a display-space of EXTRA-PIXELS to the right of every line in STRING.
@ -1583,11 +1632,14 @@ PLIST accepts the same properties as `ebox-create', such as :width,
;;;###autoload
(defun ebox-row (&rest nodes)
"Return a lazy horizontal layout node from NODES.
Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'."
Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'.
A sole child Range keeps a material row parent so the Range stays addressable."
(setq nodes (delq nil nodes))
(cond
((null nodes) (ebox-spacer))
((null (cdr nodes)) (car nodes))
((and (null (cdr nodes))
(not (ebox-child-range--descriptor-p (car nodes))))
(car nodes))
(t (list :ebox-type 'concat
:display '(block row)
:children nodes))))
@ -1598,13 +1650,17 @@ Children are rendered; shorter children are padded with blank lines so all
reach the same height before horizontal concatenation."
(let* ((rendered (mapcar #'ebox--render-with-cache
(ebox--layout-children node)))
(max-h (apply #'max (mapcar #'ebox-string-height rendered)))
(max-h (if rendered
(apply #'max (mapcar #'ebox-string-height rendered))
0))
(line-lists
(mapcar (lambda (string)
(ebox-string-lines
(ebox--height-pad string max-h)))
rendered)))
(ebox-lines-join (apply #'cl-mapcar #'concat line-lists))))
(if line-lists
(ebox-lines-join (apply #'cl-mapcar #'concat line-lists))
"")))
;;;###autoload
(defun ebox-stack (node1 node2)
@ -1623,11 +1679,15 @@ is called. To stack more than two boxes, nest calls:
;;;###autoload
(defun ebox-column (&rest nodes)
"Return a lazy vertical layout node from NODES.
Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'."
Nil entries are ignored. With zero non-nil nodes, return `ebox-spacer'.
A sole child Range keeps a material column parent so the Range stays
addressable."
(setq nodes (delq nil nodes))
(cond
((null nodes) (ebox-spacer))
((null (cdr nodes)) (car nodes))
((and (null (cdr nodes))
(not (ebox-child-range--descriptor-p (car nodes))))
(car nodes))
(t (list :ebox-type 'stack
:display '(block column)
:children nodes))))
@ -2497,8 +2557,19 @@ no included vertical margin."
for source-line in content-lines
for idx from start-index
collect
(let* ((blank-replacement-p
(let* ((child-ownership-uniform-p
(cl-every
(lambda (property)
(not
(text-property-not-all
0 (length source-line) property
(get-text-property 0 property source-line)
source-line)))
'(ebox-content ebox-content-idx
ebox-content-owner ebox-content-owners)))
(blank-replacement-p
(and (string-blank-p source-line)
child-ownership-uniform-p
(not (ebox--line-has-non-content-properties-p
source-line))))
(line
@ -2508,16 +2579,36 @@ no included vertical margin."
0 'ebox-content source-line))
(content-idx
(get-text-property
0 'ebox-content-idx source-line)))
(if content-id
(propertize padding-line-filler
'ebox-content content-id
'ebox-content-idx content-idx
'ebox-content-owner region-id)
(propertize padding-line-filler
'ebox-content region-id
'ebox-content-idx idx
'ebox-content-owner region-id)))
0 'ebox-content-idx source-line))
(content-owner
(get-text-property
0 'ebox-content-owner source-line))
(content-owners
(get-text-property
0 'ebox-content-owners source-line)))
(if (or content-owner content-owners)
(ebox--propertize-content-line
(propertize
padding-line-filler
'ebox-content content-id
'ebox-content-idx content-idx
'ebox-content-owner
(or content-owner content-id)
'ebox-content-owners
(or content-owners
(list content-owner)))
region-id idx padding-line-filler)
(if content-id
(propertize
padding-line-filler
'ebox-content content-id
'ebox-content-idx content-idx
'ebox-content-owner region-id)
(propertize
padding-line-filler
'ebox-content region-id
'ebox-content-idx idx
'ebox-content-owner region-id))))
(let* ((colored-p (or color bgcolor))
(owned-source
(if colored-p
@ -2833,6 +2924,36 @@ only used for incomplete lazy prefixes."
(defun ebox--format-scroll-window-content (box)
"Return a lazily rendered scroll content window for BOX, or nil."
(catch 'ebox-scroll-window-cached
(let* ((cached ebox--scroll-window-cached-state)
(cached-box (and cached (plist-get cached :box)))
(cached-id (and cached-box (plist-get cached-box :node-id)))
(box-id (and (listp box) (plist-get box :node-id)))
(cached-rendered-lines
(and cached (plist-get cached :rendered-content-lines)))
(use-cached-rendered-lines-p
(and ebox--scroll-window-skip-state-rebuild-p
cached-rendered-lines))
(cached-lines (and cached
(or (and use-cached-rendered-lines-p
cached-rendered-lines)
(plist-get cached :content-lines)))))
(when (and cached cached-box cached-lines box-id
(equal box-id cached-id)
(eq (ebox-get box :overflow) 'scroll))
(setq ebox--scroll-window-render-result
(list :complete (plist-get cached :content-lines-complete-p)
:content-height (plist-get cached :content-height)
:scroll-window-producer-spec
(plist-get cached :scroll-window-producer-spec)
:render-content-prefix
(plist-get cached :render-content-prefix)
:materialize-content-lines
(plist-get cached :materialize-content-lines)
:cached-rendered-lines-p
(and use-cached-rendered-lines-p t)))
(throw 'ebox-scroll-window-cached
(ebox-lines-join cached-lines))))
(let ((content-height (ebox--scroll-window-content-height box))
(scroll-offset (max 0 (or (ebox-get box :scroll-offset) 0)))
(content-viewport (ebox--wrapper-content-viewport-pixel box))
@ -2882,7 +3003,7 @@ only used for incomplete lazy prefixes."
(ebox-lines-join
(if (plist-get window :complete)
formatted-lines
(seq-take formatted-lines target-lines)))))))))
(seq-take formatted-lines target-lines))))))))))
(defun ebox--render-stack (node)
"Render a stack NODE to a string (internal, called by `ebox-render').
@ -2898,7 +3019,9 @@ stack itself uses its natural max child width."
(cons rendered
(ebox--string-max-pixel-width rendered))))
(ebox--stack-leaves node)))
(max-w (apply #'max (mapcar #'cdr rendered-items)))
(max-w (if rendered-items
(apply #'max (mapcar #'cdr rendered-items))
0))
(target-w (if ebox--intrinsic-layout-measurement
max-w
(or (ebox--viewport-pixel-width nil) max-w))))

View File

@ -1812,7 +1812,7 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
content-min
(not (eq content-min missing))))
(ebox-native-reflow--compile-flex-item item axis)))
(plist-get node :children))))))
(ebox-tree-layout-children node))))))
(if wrapper
(ebox-native-reflow--compile-box wrapper inner t)
inner)))
@ -1842,6 +1842,7 @@ Grid deliberately stays on the ordinary Ebox renderer until the native
backend has a matching two-dimensional layout contract."
(cond
((or (null node) (stringp node) (not (listp node))) t)
((plist-get node :ebox-child-sequence) nil)
((eq (plist-get node :ebox-type) 'grid) nil)
((eq (plist-get node :ebox-type) 'box)
(ebox-native-reflow--native-node-supported-p
@ -1853,7 +1854,7 @@ backend has a matching two-dimensional layout contract."
(cl-every #'ebox-native-reflow--native-node-supported-p
(append (when-let ((box (plist-get node :box)))
(list box))
(plist-get node :children))))
(ebox-tree-layout-children node))))
((eq (plist-get node :ebox-type) 'flex-item)
(ebox-native-reflow--native-node-supported-p (plist-get node :node)))
(t nil)))

View File

@ -22,6 +22,17 @@
(defvar ebox--render-owned-text-values nil
"Candidate-local text-property values explicitly created by Ebox.")
(cl-defstruct (ebox--paint-origin
(:constructor ebox--paint-origin-create))
"Transient caller-face baseline captured before Ebox paint composition."
baseline)
(defconst ebox--paint-origin-property 'ebox--paint-origin
"Private transient property carrying one render-time paint baseline.")
(defvar ebox--paint-origin-capture-p nil
"Non-nil while production Ebox rendering captures paint origins.")
(defvar ebox--render-output-provenance-table
(make-hash-table :test #'eq :weakness 'key)
"Weak map from rendered strings to Ebox-owned property values.")
@ -50,6 +61,47 @@ REGISTRY defaults to the active render candidate."
(when-let ((values (ebox--render-owned-text-values-for property)))
(gethash value values))))
(defun ebox--capture-paint-origins! (string start end)
"Capture caller FACE baselines on STRING between START and END.
Existing origins are preserved so nested Ebox contributions never replace the
original caller baseline. The property is stripped before a TP plan leaves
the render boundary."
(when (and ebox--paint-origin-capture-p
(stringp string)
(< start end))
(let ((position start))
(while (< position end)
(let* ((next (min end
(or (next-single-property-change
position ebox--paint-origin-property string end)
end)))
(origin (get-text-property
position ebox--paint-origin-property string)))
(unless origin
(put-text-property
position next ebox--paint-origin-property
(ebox--paint-origin-create
:baseline (copy-tree (get-text-property position 'face string)))
string))
(setq position (max next (1+ position)))))))
string)
(defun ebox--add-render-face! (string start end face &optional append)
"Add Ebox FACE to STRING after capturing its caller baseline."
(ebox--capture-paint-origins! string start end)
(add-face-text-property start end face append string))
(defun ebox--strip-paint-origins! (string)
"Remove transient caller-face carriers from STRING before it escapes.
The carrier is retained only while an active surface producer harvests its
baseline. Public/ephemeral renders must never expose this private property,
including strings returned from a render-cache hit."
(when (and (stringp string)
(> (length string) 0))
(remove-text-properties
0 (length string) (list ebox--paint-origin-property nil) string))
string)
(defun ebox--register-render-owned-face-values (source rendered)
"Register Ebox-generated face identities added to RENDERED from SOURCE."
(when (and (stringp source)

View File

@ -227,6 +227,18 @@
(defvar ebox-style-schemas (ecss-schema-set-create)
"ECSS property schemas for the isolated Ebox style domain.")
(defvar ebox-style--registered-schemas nil
"Schema-set object whose Ebox properties are already registered.
`ecss-schema-set-property' returns a detached schema copy, so probing it on
every `ebox-create' needlessly copies the entire property metadata domain.")
(defconst ebox-style--declaration-cache-max-entries 512
"Maximum canonical declaration compilations retained by Ebox.")
(defvar ebox-style--declaration-cache
(make-hash-table :test 'equal)
"Bounded memo table for canonical Ebox declaration compilation.")
(defun ebox-style--ensure-property-table ()
"Return the canonical style property registry table."
(or ebox-style--property-table
@ -590,12 +602,18 @@
(dolist (property ebox-style--property-definitions)
(apply #'ecss-schema-set-define
ebox-style-schemas (plist-get property :id)
(ebox-style--schema-options property))))
(ebox-style--schema-options property)))
(setq ebox-style--registered-schemas ebox-style-schemas))
(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)))
(unless (eq ebox-style--registered-schemas ebox-style-schemas)
;; Only the first call for a new schema-set needs to inspect/register the
;; domain. Avoid the detached `ecss-schema-set-property' accessor on the
;; hot `ebox-create' path; it recursively copies schema metadata.
(if (ecss-schema-set-property ebox-style-schemas 'ebox/color)
(setq ebox-style--registered-schemas ebox-style-schemas)
(ebox-style-register-properties))))
(defvar ebox-style-stylesheet (ecss-stylesheet-create)
"Isolated ECSS stylesheet containing Ebox layout and paint rules.")
@ -628,6 +646,21 @@ cascade pass during its initial static projection."
"Return non-nil when PLIST is a proper even-length list."
(and (proper-list-p plist) (zerop (% (length plist) 2))))
(defun ebox-style--declaration-cache-key (plist strict)
"Return a stable style-only cache key for PLIST and STRICT.
Non-style node properties such as content, Host references, and callbacks do
not affect declaration compilation and are intentionally excluded."
(let (canonical)
(cl-loop for (property value) on plist by #'cddr
for schema = (ebox-style-schema-id property)
if schema
do (setq canonical
(append canonical (list schema value)))
else if (ebox-style--custom-property-p property)
do (setq canonical
(append canonical (list property value))))
(list strict canonical)))
(defun ebox-style--static-invalid-declaration-p (validator value)
"Return non-nil when VALIDATOR rejects static VALUE."
(pcase validator
@ -657,17 +690,29 @@ 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))))
(let* ((cache-key (ebox-style--declaration-cache-key plist strict))
(missing (make-symbol "ebox-style-declaration-cache-missing"))
(cached (gethash cache-key ebox-style--declaration-cache missing)))
(if (not (eq cached missing))
;; Canonical declaration plists are immutable after compilation;
;; return a fresh top-level plist so callers own their list spine
;; without re-running ECSS's recursive boundary copier.
(copy-sequence cached)
(let ((canonical (cadr cache-key)))
(when strict
(cl-loop for (property _value) on plist by #'cddr
unless (or (ebox-style-schema-id property)
(ebox-style--custom-property-p property))
do (user-error "Unknown Ebox style property: %S"
property)))
(let ((compiled
(ebox-style--validate-declaration-values
(ecss-merge-declarations ebox-style-schemas canonical))))
(when (>= (hash-table-count ebox-style--declaration-cache)
ebox-style--declaration-cache-max-entries)
(clrhash ebox-style--declaration-cache))
(puthash cache-key compiled ebox-style--declaration-cache)
(copy-sequence compiled))))))
(defun ebox-style-merge-declarations (base overrides)
"Merge canonical BASE and OVERRIDES through ECSS property schemas."
@ -693,6 +738,177 @@ ORIGIN, LAYER, and SCOPE use ECSS cascade semantics."
:stylesheet ebox-style-stylesheet :parent-style parent-style
:value-resolver #'tp-resolve-value))
(defconst ebox-style--theme-delta-properties
'(ebox/color ebox/background-color)
"Canonical paint properties eligible for a declaration-only Theme delta.")
(defconst ebox-style--paint-delta-properties
'(ebox/color ebox/background-color
ebox/border-top-color ebox/border-right-color
ebox/border-bottom-color ebox/border-left-color)
"Canonical paint properties that may change without changing geometry.")
(defun ebox-style--without-theme-delta-properties (declarations)
"Return DECLARATIONS without the canonical Theme paint properties."
(cl-loop for (property value) on declarations by #'cddr
unless (memq property ebox-style--theme-delta-properties)
append (list property value)))
(defun ebox-style--without-paint-delta-properties (declarations)
"Return DECLARATIONS without canonical paint-only properties."
(cl-loop for (property value) on declarations by #'cddr
unless (memq property ebox-style--paint-delta-properties)
append (list property value)))
(defun ebox-style--paint-declarations-equivalent-p (old new)
"Return non-nil when OLD and NEW differ only in paint declarations.
All paint properties retain presence parity; layout, structure, and typography
declarations must remain identical."
(and (equal (ebox-style--without-paint-delta-properties old)
(ebox-style--without-paint-delta-properties new))
(cl-every
(lambda (property)
(= (if (plist-member old property) 1 0)
(if (plist-member new property) 1 0)))
ebox-style--paint-delta-properties)))
(defun ebox-style--theme-declarations-equivalent-p (old new)
"Return non-nil when OLD and NEW differ only in explicit Theme paint values.
Both paint properties must retain presence parity and an explicit foreground
winner is required so parent inherited geometry/paint cannot be stale."
(and (equal (ebox-style--without-theme-delta-properties old)
(ebox-style--without-theme-delta-properties new))
(cl-every
(lambda (property)
(= (if (plist-member old property) 1 0)
(if (plist-member new property) 1 0)))
ebox-style--theme-delta-properties)
(plist-member old 'ebox/color)
(plist-member new 'ebox/color)))
(defconst ebox-style--inherited-properties
'(ebox/color ebox/font ebox/font-family ebox/font-height
ebox/font-weight ebox/font-slant)
"Inherited ECSS properties whose parent values guard Theme deltas.")
(defun ebox-style--inherited-style-signature (style)
"Return the inherited-value fingerprint of computed STYLE, or nil.
The fingerprint deliberately contains only values that can flow from a
parent into a descendant, plus custom properties which can feed any
`var' expression. A Theme delta may reuse a computed style only when this
fingerprint is unchanged between the old and candidate parent."
(when (ecss-computed-style-p style)
(list
:inherited
(mapcar (lambda (property)
(cons property (ecss-computed-style-value style property)))
ebox-style--inherited-properties)
:custom-properties (ecss-computed-style-custom-properties style))))
(defun ebox-style--theme-delta-computed
(style old-declarations new-declarations
&optional old-parent-style new-parent-style)
"Return a cheap STYLE copy for a safe Theme paint-only declaration delta.
OLD-DECLARATIONS and NEW-DECLARATIONS are the canonical ECSS plists.
OLD-PARENT-STYLE and NEW-PARENT-STYLE are the previous and candidate
computed parent styles, when the subject has an inherited parent.
The cascade selector facts and every non-Theme declaration must be unchanged;
both Theme properties must keep the same explicit-presence state, and the
inherited parent fingerprint must be unchanged. In that case only the
computed values and winner metadata for those properties change. Any other
shape returns nil so the caller uses the full ECSS computation."
(when (and (ecss-computed-style-p style)
(ebox-style--theme-declarations-equivalent-p
old-declarations new-declarations))
(let ((changed-p
(cl-some
(lambda (property)
(and (plist-member old-declarations property)
(plist-member new-declarations property)
(not (equal (plist-get old-declarations property)
(plist-get new-declarations property)))))
ebox-style--theme-delta-properties)))
(when (and changed-p
(equal (ebox-style--inherited-style-signature
old-parent-style)
(ebox-style--inherited-style-signature
new-parent-style)))
(let ((values (copy-tree (ecss-computed-style-values style)))
(active (copy-sequence
(ecss-computed-style-active-properties style)))
(specified (copy-sequence
(ecss-computed-style-specified-properties style))))
(dolist (property ebox-style--theme-delta-properties)
(let ((id (ebox-style-schema-id property)))
(when (plist-member new-declarations property)
(setf values
(plist-put values id
(copy-tree (plist-get new-declarations property))))
(cl-pushnew id active)
(cl-pushnew id specified))))
(ecss-computed-style-copy-with-values
style values :active-properties active :specified-properties specified))))))
(defun ebox-style--inherited-style-signature-without-theme-color (style)
"Return STYLE's inherited fingerprint excluding the Theme color value.
The caller uses this to prove that a parent update changes only the inherited
foreground value; fonts, custom properties, and all other inherited facts must
remain identical before a computed child style may be copied."
(when (ecss-computed-style-p style)
(list
:inherited
(mapcar
(lambda (property)
(cons property (ecss-computed-style-value style property)))
(delq 'ebox/color (copy-sequence ebox-style--inherited-properties)))
:custom-properties (ecss-computed-style-custom-properties style))))
(defun ebox-style--theme-parent-delta-computed
(style declarations old-parent-style new-parent-style)
"Return a copied STYLE when a static parent changes only Theme color.
For an explicit child color, STYLE is reusable unchanged. For an inherited
child color, the copied value follows NEW-PARENT-STYLE. OLD-PARENT-STYLE and
NEW-PARENT-STYLE must have identical non-color inherited fingerprints; any
provenance or parent-shape mismatch returns nil for normal ECSS computation."
(when (and (ecss-computed-style-p style)
(proper-list-p declarations)
(ecss-computed-style-p old-parent-style)
(ecss-computed-style-p new-parent-style)
(equal
(ebox-style--inherited-style-signature-without-theme-color
old-parent-style)
(ebox-style--inherited-style-signature-without-theme-color
new-parent-style)))
(if (ecss-computed-style-specified-p style 'ebox/color)
(ecss-computed-style-copy-with-values
style (ecss-computed-style-values style))
(when (and (equal (ecss-computed-style-value
style 'ebox/color)
(ecss-computed-style-value
old-parent-style 'ebox/color))
(not (equal (ecss-computed-style-value
old-parent-style 'ebox/color)
(ecss-computed-style-value
new-parent-style 'ebox/color))))
(let ((values (copy-tree (ecss-computed-style-values style))))
(setq values
(plist-put values 'ebox/color
(copy-tree
(ecss-computed-style-value
new-parent-style 'ebox/color))))
(ecss-computed-style-copy-with-values style values))))))
(defun ebox-style--theme-inherited-delta-computed
(style declarations old-parent-style new-parent-style)
"Return a copied STYLE when only inherited Theme color changed.
This compatibility entry point requires STYLE to inherit its color; explicit
child colors are handled by `ebox-style--theme-parent-delta-computed'."
(when (and (ecss-computed-style-p style)
(not (ecss-computed-style-specified-p style 'ebox/color)))
(ebox-style--theme-parent-delta-computed
style declarations old-parent-style new-parent-style)))
(defun ebox-style--public-computed-values (style)
"Return STYLE values using canonical public Ebox property names."
(let ((values (ecss-computed-style-values style)) result)

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
(require 'subr-x)
(require 'ecss-selector)
(require 'ebox-style)
(require 'ebox-child-range)
(defconst ebox--default-display '(block flow)
"Default CSS-like display for a plain Ebox box.")
@ -138,11 +139,11 @@ This is an optimization for snapshot capture, not buffer runtime state.")
('flex
(append (when-let ((box (plist-get node :box)))
(list box))
(plist-get node :children)))
(ebox-tree-layout-children node)))
('grid
(append (when-let ((box (plist-get node :box)))
(list box))
(plist-get node :children)))
(ebox-tree-layout-children node)))
('flex-item
(delq nil (list (plist-get node :node))))
(_ nil)))
@ -157,15 +158,17 @@ must not modify a published runtime tree through this accessor."
"Return layout container children for NODE in render order.
New row/column containers store flat `:children'. Legacy `ebox-concat' and
`ebox-stack' nodes may still carry `:left'/`:right' or `:top'/`:bottom'."
(or (plist-get node :children)
(pcase (and (listp node) (plist-get node :ebox-type))
('concat
(delq nil (list (plist-get node :left)
(plist-get node :right))))
('stack
(delq nil (list (plist-get node :top)
(plist-get node :bottom))))
(_ nil))))
(if (plist-member node :ebox-child-sequence)
(ebox-child-range--flatten (plist-get node :ebox-child-sequence))
(or (plist-get node :children)
(pcase (and (listp node) (plist-get node :ebox-type))
('concat
(delq nil (list (plist-get node :left)
(plist-get node :right))))
('stack
(delq nil (list (plist-get node :top)
(plist-get node :bottom))))
(_ nil)))))
(defun ebox-tree--replace-direct-child (child replacements)
"Return CHILD's identity replacement from REPLACEMENTS, when present.
@ -242,6 +245,55 @@ still copied while every nested value remains shared."
(ebox-tree--replace-direct-child-list children replacements)))
(unless (eq replaced children)
(setq copy (plist-put copy :children replaced)))))
(when-let ((sequence (and (memq type '(concat stack flex grid))
(plist-get copy :ebox-child-sequence))))
(let ((updated sequence) changed fallback)
(dolist (replacement replacements)
(let* ((old (car replacement)) (new (cdr replacement))
(location (and old (plist-get old :ebox-sequence-location)))
(key (and old (plist-get old :key)))
(key-location
(and (null location) key
(ebox-child-range--hash-lookup
(ebox-child-range--sequence-key-root updated) key
(funcall
(ebox-child-range--sequence-hash-function updated)
key)))))
(when key-location
(let* ((segment (ebox-child-range--segment-at
updated (car key-location)))
(candidate (aref
(ebox-child-range--segment-payload segment)
(cdr key-location))))
(when (eq candidate old)
(setq location
(list :parent-node-id (plist-get node :node-id)
:segment-index (car key-location)
:offset (cdr key-location))))))
(when (and location
(equal (plist-get location :parent-node-id)
(plist-get node :node-id)))
(if new
(setq updated
(ebox-child-range--replace-item-at
updated (plist-get location :segment-index)
(plist-get location :offset) old new)
changed t)
(setq fallback t)))))
(when fallback
(let (segments)
(dotimes (index (ebox-child-range--sequence-count sequence))
(let* ((segment (ebox-child-range--segment-at sequence index))
(items (append
(ebox-child-range--segment-payload segment) nil)))
(push (cons (ebox-child-range--segment-ref segment)
(ebox-tree--replace-direct-child-list
items replacements))
segments)))
(setq updated (ebox-child-range--build (nreverse segments))
changed t)))
(when changed
(setq copy (plist-put copy :ebox-child-sequence updated)))))
copy))
(defun ebox-tree-copy-node-structure (root)
@ -250,15 +302,57 @@ The returned tree owns distinct node plists and child lists. Values such as
keymaps, callbacks, Host references, and surface-property payloads retain their
original object identity."
(cl-labels
((copy-node
((without (plist key)
(cl-loop for (property value) on plist by #'cddr
unless (eq property key) append (list property value)))
(copy-node
(node)
(if (or (not (listp node)) (stringp node))
node
(ebox-tree-copy-with-direct-child-replacements
node
(mapcar (lambda (child)
(cons child (copy-node child)))
(ebox-tree--children-raw node))))))
(let* ((type (plist-get node :ebox-type))
(material-p (memq type '(concat stack flex grid)))
(raw (and material-p (plist-get node :children)))
(sequence (and material-p
(plist-get node :ebox-child-sequence)))
(has-range (cl-some #'ebox-child-range--descriptor-p raw)))
(if (or has-range sequence)
(let ((copy (copy-sequence node)) segments)
(if sequence
(dotimes (index
(ebox-child-range--sequence-count sequence))
(let ((segment
(ebox-child-range--segment-at sequence index)))
(push
(cons (ebox-child-range--segment-ref segment)
(mapcar #'copy-node
(append
(ebox-child-range--segment-payload
segment)
nil)))
segments)))
(dolist (entry raw)
(if (ebox-child-range--descriptor-p entry)
(push (cons (ebox-child-range--descriptor-ref entry)
(mapcar
#'copy-node
(ebox-child-range--descriptor-items entry)))
segments)
(push (cons nil (list (copy-node entry))) segments))))
(setq copy (without copy :children))
(pcase type
((or 'flex 'grid)
(when-let ((box (plist-get node :box)))
(setq copy (plist-put copy :box (copy-node box)))))
('concat
(setq copy (without (without copy :left) :right)))
('stack
(setq copy (without (without copy :top) :bottom))))
(plist-put copy :ebox-child-sequence
(ebox-child-range--build (nreverse segments))))
(ebox-tree-copy-with-direct-child-replacements
node
(mapcar (lambda (child) (cons child (copy-node child)))
(ebox-tree--children-raw node))))))))
(copy-node root)))
(defun ebox-tree--runtime-identity-node-shell
@ -312,7 +406,7 @@ Record its identities in NODE-ID-SET and REGION-ID-SET."
(lambda (child)
(ebox-tree--runtime-identity-skeleton
child node-id-set region-id-set))
(plist-get node :children))))
(ebox-tree-layout-children node))))
('flex-item
(when-let ((child (plist-get node :node)))
(plist-put shell :node
@ -368,7 +462,10 @@ Host references are root-global opaque metadata and are compared with `equal'."
(puthash node t seen)
(puthash node t active)
(dolist (child (ebox-tree--children-raw node))
(visit child))
(if (ebox-child-range--descriptor-p child)
(dolist (item (ebox-child-range--descriptor-items child))
(visit item))
(visit child)))
(remhash node active))))))
(visit root))
root))
@ -380,9 +477,12 @@ The runtime tree must be a proper tree: a node object cannot appear in two
locations, cycles are rejected, explicit sibling keys must be unique, and
non-nil host references must be unique across the root. Keys are sibling-local
and all explicit identities are compared with `equal'. Return ROOT on success."
(when (ebox-child-range--descriptor-p root)
(error "Ebox child Range descriptor cannot be the root"))
(ebox-tree-validate-host-refs root)
(let ((seen (make-hash-table :test 'eq))
(active (make-hash-table :test 'eq)))
(active (make-hash-table :test 'eq))
(range-refs (make-hash-table :test 'equal)))
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
@ -392,8 +492,36 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(error "Ebox declarative tree reuses one node object"))
(puthash node t seen)
(puthash node t active)
(let ((keys (make-hash-table :test 'equal)))
(dolist (child (ebox-tree--children-raw node))
(let* ((keys (make-hash-table :test 'equal))
(type (plist-get node :ebox-type))
(raw (and (memq type '(concat stack flex grid))
(plist-get node :children)))
children)
(when (and raw (not (proper-list-p raw)))
(error "Ebox material children must be a proper list"))
(dolist (entry (ebox-tree--children-raw node))
(if (ebox-child-range--descriptor-p entry)
(progn
(unless (and (proper-list-p raw) (memq entry raw))
(error "Ebox child Range descriptor is invalid in a scalar slot"))
(let ((ref (ebox-child-range--descriptor-ref entry))
(items (ebox-child-range--descriptor-items entry)))
(unless (and ref (proper-list-p items))
(error "Ebox child Range descriptor is invalid"))
(when (gethash ref range-refs)
(error "Ebox child Range ref is not unique: %S" ref))
(puthash ref t range-refs)
(dolist (item items)
(when (ebox-child-range--descriptor-p item)
(error "Nested child Range descriptors are reserved"))
(unless (and (listp item) (not (stringp item)))
(error "Ebox child Range item must be a node"))
(push item children))))
(progn
(when (ebox-child-range--descriptor-p entry)
(error "Ebox child Range descriptor is invalid here"))
(push entry children))))
(dolist (child (nreverse children))
(when-let ((key (and (listp child)
(not (stringp child))
(plist-get child :key))))
@ -418,17 +546,21 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(plist-put node :region-id nil)))
(when (plist-member node :surface-object)
(plist-put node :surface-object nil))
(when (plist-member node :ebox-sequence-location)
(plist-put node :ebox-sequence-location nil))
(dolist (child (ebox-tree--children-raw node))
(visit child)))))
(visit root))
root)
(defconst ebox-tree--runtime-source-keys
'(:node-id :region-id :surface-object :render-cache)
'(:node-id :region-id :surface-object :render-cache :ebox-sequence-location
:ebox-content-width-exact-p)
"Runtime-owned plist keys excluded from declarative source signatures.")
(defconst ebox-tree--child-source-keys
'(:children :left :right :top :bottom :node :box :ebox-content-node)
'(:children :ebox-child-sequence :left :right :top :bottom :node :box
:ebox-content-node)
"Child references excluded from node-local declarative signatures.")
(defconst ebox-tree--excluded-source-keys
@ -449,21 +581,76 @@ projected Ebox longhands carry the actual paint and layout consequences.")
"Return keys from PLIST in source order."
(cl-loop for (key _value) on plist by #'cddr collect key))
(defun ebox-tree--layout-props-source-signature (node)
"Return canonical layout-only props for a Grid/Flex NODE.
Raw layout props also carry derived paint aliases, host references, duplicate
width entries, and shorthand gap forms. Those fields are already represented
by style declarations or runtime identity and must not make a Theme repaint
look like a geometry mutation."
(when (memq (plist-get node :ebox-type) '(grid flex))
(let* ((raw (or (plist-get node :raw-props)
(plist-get node :props)))
;; Compare the same engine vocabulary used by Ebox rendering. A
;; retained node may arrive once from an already-expanded style
;; snapshot and later from fresh shorthand declarations; comparing
;; those source spellings directly creates fake geometry dirtiness.
(raw (ebox-style-expand-ebox-plist raw))
(table (make-hash-table :test #'eq)))
(cl-loop for (key value) on raw by #'cddr
unless (memq key '(:color :background-color :bgcolor :host-ref
:surface-properties))
do (puthash key value table))
(when (plist-member raw :gap)
(let* ((gap (plist-get raw :gap))
(row (if (consp gap) (car gap) gap))
(column (if (and (consp gap) (cdr gap)) (cadr gap) row))
;; Flex and Grid deliberately have separate canonical
;; property vocabularies. Using Grid aliases for a Flex node
;; makes an unchanged `gap' look like geometry dirtiness on
;; the next retained generation.
(row-key (if (eq (plist-get node :ebox-type) 'grid)
:grid-row-gap
:row-gap))
(column-key (if (eq (plist-get node :ebox-type) 'grid)
:grid-column-gap
:column-gap)))
(puthash row-key row table)
(puthash column-key column table)
(remhash :gap table)))
(let (keys)
(maphash (lambda (key _value) (push key keys)) table)
(setq keys
(sort keys
(lambda (left right)
(string< (symbol-name left)
(symbol-name right)))))
(cl-loop for key in keys
append (list key (gethash key table)))))))
(defun ebox-tree--local-source-signature (node)
"Return NODE's filtered local declarative source signature."
(let ((plist node)
signature)
(while plist
(let ((key (pop plist))
(value (pop plist)))
(unless (or (memq key ebox-tree--excluded-source-keys)
(and (memq key '(:props :raw-props))
(memq (plist-get node :ebox-type) '(grid flex))))
(push key signature)
(push value signature))))
(when-let ((layout (ebox-tree--layout-props-source-signature node)))
(push :ebox-layout-props signature)
(push layout signature))
(nreverse signature)))
(defun ebox-tree-node-local-source-signature (node)
"Return NODE's non-recursive declarative source signature.
Runtime identities, caches, and child pointers are excluded. Child order is
compared independently through stable runtime ids, so a leaf change does not
mark every ancestor dirty."
(let ((plist node)
signature)
(while plist
(let ((key (pop plist))
(value (pop plist)))
(unless (memq key ebox-tree--excluded-source-keys)
(push key signature)
(push value signature))))
(nreverse signature)))
(ebox-tree--local-source-signature node))
(defun ebox-tree--next-local-source-entry (plist)
"Return PLIST's next node-local source entry, skipping excluded keys."
@ -489,24 +676,25 @@ mark every ancestor dirty."
(defun ebox-tree-node-local-changed-keys (old new)
"Return ordered declarative keys whose node-local values differ.
OLD and NEW are source nodes with the same retained runtime identity."
(unless (ebox-tree--local-source-sequence-equal-p old new)
(let (seen changed)
(dolist (source (list old new))
(let ((plist source))
(while plist
(let ((key (pop plist)))
(pop plist)
(unless (or (memq key ebox-tree--excluded-source-keys)
(memq key seen))
(push key seen)
(let ((old-entry (plist-member old key))
(new-entry (plist-member new key)))
(unless (and (eq (not (null old-entry))
(not (null new-entry)))
(equal-including-properties
(cadr old-entry) (cadr new-entry)))
(push key changed))))))))
(nreverse changed))))
(let ((old-signature (ebox-tree--local-source-signature old))
(new-signature (ebox-tree--local-source-signature new))
seen changed)
(dolist (source (list old-signature new-signature))
(let ((plist source))
(while plist
(let ((key (pop plist)))
(pop plist)
(unless (memq key seen)
(push key seen)
(let ((old-entry (plist-member old-signature key))
(new-entry (plist-member new-signature key)))
(unless (and (eq (not (null old-entry))
(not (null new-entry)))
(equal-including-properties
(cadr old-entry) (cadr new-entry)))
(push (if (eq key :ebox-layout-props) :props key)
changed))))))))
(nreverse changed)))
(defun ebox-tree-transfer-runtime-identity (old new)
"Transfer retained runtime identity from matching OLD to NEW.
@ -522,6 +710,9 @@ candidate runtime index agree."
(plist-get new :ebox-type)))
(progn
(plist-put new :node-id (ebox--ensure-node-id old))
(when (plist-member old :ebox-sequence-location)
(plist-put new :ebox-sequence-location
(copy-sequence (plist-get old :ebox-sequence-location))))
(when (eq (plist-get new :ebox-type) 'box)
(plist-put new :region-id (ebox--ensure-region-id old))
;; Omitted scroll state is uncontrolled host state. `ebox-create'
@ -615,8 +806,8 @@ candidate runtime index agree."
((or 'concat 'stack)
(ebox-tree--semantic-layout-leaves node))
('flex
(mapcar #'ebox-tree-flex-item-source-node (plist-get node :children)))
('grid (plist-get node :children))
(mapcar #'ebox-tree-flex-item-source-node (ebox-tree-layout-children node)))
('grid (ebox-tree-layout-children node))
('flex-item (delq nil (list (plist-get node :node))))
(_ (ebox-tree-children node))))
@ -742,14 +933,14 @@ not only the public update ids."
(list (ebox--ensure-region-id box)))
(apply #'append
(mapcar #'ebox-tree-node-all-region-ids
(plist-get node :children)))))
(ebox-tree-layout-children node)))))
('grid
(append
(when-let ((box (plist-get node :box)))
(list (ebox--ensure-region-id box)))
(apply #'append
(mapcar #'ebox-tree-node-all-region-ids
(plist-get node :children)))))
(ebox-tree-layout-children node)))))
('flex-item
(ebox-tree-node-all-region-ids (plist-get node :node)))
(_ nil))))
@ -786,14 +977,14 @@ cannot be inspected and reports no conflict."
(and (plist-get box paint-key) t)))
(cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t))
(plist-get node :children))))
(ebox-tree-layout-children node))))
('grid
(or (and inherited
(when-let ((box (plist-get node :box)))
(and (plist-get box paint-key) t)))
(cl-some (lambda (child)
(ebox-tree-node-paint-conflict-p child paint-key t))
(plist-get node :children))))
(ebox-tree-layout-children node))))
('flex-item
(ebox-tree-node-paint-conflict-p
(plist-get node :node) paint-key inherited))
@ -834,7 +1025,7 @@ wrapper's visible content is produced by the flex renderer."
(list node))
(t
(catch 'found
(dolist (child (plist-get node :children))
(dolist (child (ebox-tree-layout-children node))
(when-let ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node)))))
nil))))
@ -845,7 +1036,7 @@ wrapper's visible content is produced by the flex renderer."
(list node))
(t
(catch 'found
(dolist (child (plist-get node :children))
(dolist (child (ebox-tree-layout-children node))
(when-let ((path (ebox-tree-node-path-to-region child region-id)))
(throw 'found (append path (list node)))))
nil))))
@ -873,12 +1064,12 @@ wrapper's visible content is produced by the flex renderer."
(or (and (plist-get node :box)
(ebox--box-visible-overflow-p (plist-get node :box)))
(cl-some #'ebox-tree-node-visible-overflow-p
(plist-get node :children))))
(ebox-tree-layout-children node))))
('grid
(or (and (plist-get node :box)
(ebox--box-visible-overflow-p (plist-get node :box)))
(cl-some #'ebox-tree-node-visible-overflow-p
(plist-get node :children))))
(ebox-tree-layout-children node))))
('flex-item
(ebox-tree-node-visible-overflow-p (plist-get node :node)))
(_ nil)))))

861
ebox.el

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@
"The Makefile should expose Ebox targets without bundling ETAF."
(let ((source (ebox-ci-test--read "Makefile")))
(dolist (target '("check:" "ci:" "load:" "compile:" "test:"
"core-tests:" "grid-tests:" "ebox-commit-tests:"
"core-tests:" "child-range-tests:" "grid-tests:" "ebox-commit-tests:"
"surface-tests:"
"docs-contract-tests:"
"ci-contract-tests:" "native-rust-tests:"
@ -32,6 +32,7 @@
(should (string-match-p "byte-compile-error-on-warn" source))
(should (string-match-p "ebox-byte-compile" source))
(should (string-match-p "ebox-native-reflow.el" ebox))
(should (string-match-p "ebox-child-range.el" ebox))
(should-not (string-match-p "ebox-playground.el" ebox))))
(ert-deftest ebox-ci-prefers-newer-source-and-cleans-local-products ()

File diff suppressed because it is too large Load Diff

View File

@ -4324,6 +4324,43 @@
(ebox-render box))
(should (eq (gethash region-id ebox--scroll-global-state) state))))
(ert-deftest ebox-flat-preformatted-blank-line-preserves-child-owner-segments ()
"Blank composite child segments must retain their distinct ownership."
(ebox-test--reset-runtime-state)
(let* ((left
(propertize
(ebox-pixel-space 40)
'ebox-content 'left 'ebox-content-idx 0
'ebox-content-owner 'left 'ebox-content-owners '(left)))
(right
(propertize
(ebox-pixel-space 60)
'ebox-content 'right 'ebox-content-idx 0
'ebox-content-owner 'right 'ebox-content-owners '(right)))
(box (ebox-create :content "" :width 100 :wrap-mode nil
:color "#112233" :bgcolor "#DDEEFF"))
(rendered
(car
(ebox--window-render-flat-wrapper-chunk-lines
box (list (concat left right)) 0 'outer
(ebox-pixel-space 100) t t))))
(should (= (length rendered) 2))
(should (= (ebox--string-pixel-width rendered) 100))
(should (eq (get-text-property 0 'ebox-content rendered) 'left))
(should (eq (get-text-property 1 'ebox-content rendered) 'right))
(should (equal (get-text-property 0 'ebox-content-owners rendered)
'(left outer)))
(should (equal (get-text-property 1 'ebox-content-owners rendered)
'(right outer)))
(should (eq (get-text-property 0 'ebox-content-owner rendered) 'outer))
(should (eq (get-text-property 1 'ebox-content-owner rendered) 'outer))
(dolist (position '(0 1))
(let ((face (get-text-property position 'face rendered)))
(should (ebox-test--face-has-key-value-p
face :foreground "#112233"))
(should (ebox-test--face-has-key-value-p
face :background "#DDEEFF"))))))
(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)
@ -4426,6 +4463,34 @@
(should (= justify-count 3))
(should (= (ebox--string-max-pixel-width rendered) 140))))
(ert-deftest ebox-box-content-node-publishes-only-proven-exact-width ()
"A wrapper should mark child output exact only at its proven viewport width."
(ebox-test--reset-runtime-state)
(let* ((overflow-child
(ebox-column
(ebox-create :content "A" :width 140)
(ebox-create :content "B" :width 140)))
(overflow-parent
(ebox-create :width 120 :wrap-mode nil
:ebox-content-node overflow-child))
(exact-child
(ebox-column
(ebox-create :content "A" :width 120)
(ebox-create :content "B" :width 120)))
(exact-parent
(ebox-create :width 120 :wrap-mode nil
:ebox-content-node exact-child))
(ebox-viewport-width 120)
(ebox--box-content-render-cache (make-hash-table :test 'eq))
overflow exact)
(setq overflow
(ebox--render-box-content-node overflow-parent overflow-child))
(setq exact (ebox--render-box-content-node exact-parent exact-child))
(should (= (ebox--string-max-pixel-width overflow) 140))
(should-not (plist-get overflow-parent :ebox-content-width-exact-p))
(should (= (ebox--string-max-pixel-width exact) 120))
(should (plist-get exact-parent :ebox-content-width-exact-p))))
(ert-deftest ebox-render-caches-lazy-box-content-within-render-pass ()
"A lazy child layout should be rendered once per box/context in one pass."
(ebox-test--reset-runtime-state)
@ -7667,6 +7732,7 @@
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 12)
(ebox-wheel-scroll-step 16)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
@ -8329,8 +8395,7 @@
`(box :content ,(format "complete %02d" idx)
:width (100) :height 8)))))
(limit 4)
(full-lines (mapcar #'substring-no-properties
(ebox-string-lines (ebox-render node))))
(full-lines (ebox-string-lines (ebox-render node)))
(complete-wrapper-count 0)
(original-render-box-lines
(symbol-function 'ebox--flex-window-render-box-lines)))
@ -8344,9 +8409,19 @@
(should window)
(should-not (plist-get window :complete))
(should (= complete-wrapper-count 0))
(should (equal (seq-take full-lines limit)
(mapcar #'substring-no-properties
(plist-get window :lines))))))))
(should
(cl-every
(lambda (pair)
(let ((full (car pair))
(prefix (cdr pair)))
(or (equal (substring-no-properties full)
(substring-no-properties prefix))
(and (string-blank-p full)
(string-blank-p prefix)
(= (ebox--string-pixel-width full)
(ebox--string-pixel-width prefix))))))
(cl-mapcar #'cons (seq-take full-lines limit)
(plist-get window :lines))))))))
(ert-deftest ebox-flex-window-complete-extension-appends-bottom-border ()
"Completing a cached flex prefix should append bottom border without full render."
@ -9113,7 +9188,9 @@
(original-render-box (symbol-function 'ebox--render-box)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 16)
(ebox-wheel-smooth-scroll t))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal (ebox-tree-node-id box) "row-30")
@ -9135,7 +9212,9 @@
(should (ebox--scroll-region-by root-id 31))
(should (= hidden-render-count 0))
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
(ebox--scroll-idle-prefetch root-id)
(dotimes (_ 40)
(when (= hidden-render-count 0)
(ebox--scroll-idle-prefetch root-id)))
(should (> hidden-render-count 0))
(let* ((state (ebox--scroll-get-state root-id))
(delta (- 30 (or (plist-get state :scroll-offset)
@ -10627,6 +10706,7 @@
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 16)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0))
(setq buffer
@ -10984,7 +11064,8 @@
(original-render-box (symbol-function 'ebox--render-box)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 16))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal (ebox-tree-node-id box) "row-30")
@ -11867,6 +11948,130 @@
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-visible-buffer-rearms-native-scroll-prewarm ()
"A buffer rendered off-window should switch to native scrolling when shown."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
(scheduled 0)
(fake-window 'ebox-test-window))
(unwind-protect
(let ((ebox--runtime-prewarm-allow-noninteractive t)
(ebox-native-buffer-scroll t)
;; The production hook is intentionally GUI-only. Bind the
;; flag for this lifecycle test and stub the window primitives.
(noninteractive nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-visible-prewarm*")
(ebox-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "one" :height 1)
(box :content "two" :height 1)
(box :content "three" :height 1))))))
;; Rendering happened before the buffer had a window, so the
;; original job is allowed to finish without native materializing.
(ebox--runtime-prewarm-drain buffer)
(should-not (plist-get (ebox--buffer-render-state buffer)
:native-buffer-scroll-p))
(cl-letf (((symbol-function 'window-live-p)
(lambda (window) (eq window fake-window)))
((symbol-function 'window-buffer)
(lambda (_window) buffer))
((symbol-function 'get-buffer-window)
(lambda (candidate &optional _frame)
(and (eq candidate buffer) fake-window)))
((symbol-function 'ebox--viewport-dependent-subtree-p)
(lambda (&rest _) nil))
((symbol-function
'ebox--viewport-height-dependent-subtree-p)
(lambda (&rest _) nil))
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
(lambda (candidate &optional _delay _retained-only-p)
(should (eq candidate buffer))
(cl-incf scheduled))))
(let ((render-state (ebox--buffer-render-state buffer)))
;; This fixture stands for a viewport-independent root; the
;; production proof consumes the published axes in O(1).
(plist-put render-state :viewport-dependent-node-ids-ready t)
(plist-put render-state :viewport-dependent-node-id-axes
(list nil))
(let ((job (ebox--runtime-prewarm-new-job
buffer render-state)))
(should (eq (plist-get job :phase) 'native-scroll))
(should (plist-get job :native-scroll-region-id))))
(ebox--window-buffer-change fake-window))
(should (= scheduled 1)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-native-scroll-prewarm-is-isolated-from-updates ()
"Ordinary runtime prewarm must not materialize a native scroll surface.
Native-window scrolling remains enabled, but only the initial display or an
explicit visible-window handoff may admit its full-content publication."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(let ((noninteractive nil)
(ebox--runtime-prewarm-allow-noninteractive t)
(ebox-native-buffer-scroll t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-native-scroll-opt-in*")
(ebox-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "one" :height 1)
(box :content "two" :height 1)
(box :content "three" :height 1))))))
(let ((state (ebox--buffer-render-state buffer)))
(cl-letf (((symbol-function
'ebox--native-buffer-scroll-root-region-id)
(lambda (&rest _) 1))
((symbol-function
'ebox--native-buffer-scroll-root-proof-p)
(lambda (&rest _) t)))
(let ((job (ebox--runtime-prewarm-new-job buffer state t)))
(should (eq (plist-get job :phase) 'snapshots))
(should-not (plist-get job :native-scroll-region-id)))
(let ((job (ebox--runtime-prewarm-new-job buffer state nil)))
(should (eq (plist-get job :phase) 'native-scroll))
(should (= (plist-get job :native-scroll-region-id) 1))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-native-scroll-rejects-viewport-dependent-root ()
"Native full-content materialization must not freeze a resizable root."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
(fake-window 'ebox-test-window))
(unwind-protect
(let ((noninteractive nil)
(ebox-viewport-width 120)
(ebox-viewport-height 2)
(ebox-native-buffer-scroll t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-native-scroll-viewport*")
(ebox-build
'(box :id "root" :width (viewport) :height 2
:overflow scroll
(column
(box :content "one" :height 1)
(box :content "two" :height 1)
(box :content "three" :height 1))))))
(let* ((state (ebox--buffer-render-state buffer))
(region-id (car (plist-get state :scroll-region-ids))))
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _) fake-window))
((symbol-function 'window-live-p)
(lambda (window) (eq window fake-window))))
(should-not
(ebox--native-buffer-scroll-root-proof-p
buffer region-id state)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-does-not-wait-for-prefix-prefetch ()
"Prewarm should index current scroll lines while lazy prefix work is active."
(ebox-test--reset-runtime-state)
@ -12578,11 +12783,241 @@
(should (ebox--scroll-first-region-by '(stalled active) 1)))
(should (equal (nreverse visited) '(stalled active)))))
(ert-deftest ebox-scroll-intent-distributes-forward-residual-inner-to-buffer ()
"A positive line intent should drain inner, parent, then native buffer."
(let ((capacity '((inner . 1) (middle . 2) (outer . 0)))
native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (region-id delta &optional _prefix-budget-lines)
(let* ((available (alist-get region-id capacity))
(consumed (min delta available)))
(setf (alist-get region-id capacity) (- available consumed))
consumed))))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create 5 '(inner middle outer))
(lambda (delta) (setq native-residual delta)))))
(should (= (plist-get result :requested) 5))
(should (equal (plist-get result :consumption)
'((inner . 1) (middle . 2) (outer . 0))))
(should (= (plist-get result :ebox-consumed) 3))
(should (= (plist-get result :native-residual) 2))
(should (= native-residual 2))))))
(ert-deftest ebox-scroll-intent-distributes-reverse-residual-inner-to-buffer ()
"A negative line intent should preserve direction through the owner chain."
(let ((capacity '((inner . 1) (middle . 2) (outer . 0)))
native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (region-id delta &optional _prefix-budget-lines)
(let* ((available (alist-get region-id capacity))
(magnitude (min (- delta) available))
(consumed (- magnitude)))
(setf (alist-get region-id capacity) (- available magnitude))
consumed))))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create -5 '(inner middle outer))
(lambda (delta) (setq native-residual delta)))))
(should (equal (plist-get result :consumption)
'((inner . -1) (middle . -2) (outer . 0))))
(should (= (plist-get result :ebox-consumed) -3))
(should (= (plist-get result :native-residual) -2))
(should (= native-residual -2))))))
(ert-deftest ebox-scroll-intent-distributes-real-nested-owner-capacity ()
"Nested boxes should consume their capacity before the native buffer."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(box :id "outer" :height 4 :overflow scroll
(column
(box :id "middle" :height 2 :overflow scroll
(column
(box :id "inner" :content "inner 0\ninner 1"
:height 1 :overflow scroll)
(box :content "middle 0" :height 1)
(box :content "middle 1" :height 1)
(box :content "middle 2" :height 1)))
(box :content "outer 0\nouter 1" :height 2))))))
(ebox-test--with-rendered-buffer layout
(let* ((surface (with-current-buffer
(current-buffer) ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(inner-id (ebox-test--selector-region-id
(current-buffer) "#inner"))
(middle-id (ebox-test--selector-region-id
(current-buffer) "#middle"))
(outer-id (ebox-test--selector-region-id
(current-buffer) "#outer"))
native-lines)
(should (ebox-scroll-state inner-id))
(should (ebox-scroll-state middle-id))
(should-not (ebox-scroll-state outer-id))
(goto-char (point-min))
(search-forward "inner 0")
(backward-char)
(cl-letf (((symbol-function 'scroll-up)
(lambda (lines) (setq native-lines lines))))
(ebox-scroll-down 5))
;; Each owner is currently a separately committed retained surface
;; update; the later atomic multi-owner scroll transaction will reduce
;; this to one revision without changing the consumption contract.
(should (= (tp-surface-revision surface) (+ revision 2)))
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1))
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset) 2))
(should-not (ebox-scroll-state outer-id))
(should (= native-lines 2))
(setq native-lines nil)
(ebox--scroll-consume-intent
(ebox--scroll-intent-create -5
(list inner-id middle-id outer-id))
(lambda (residual) (setq native-lines residual)))
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 0))
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset) 0))
(should-not (ebox-scroll-state outer-id))
(should (= native-lines -2))))))
(ert-deftest ebox-scroll-intent-refresh-at-boundary-does-not-swallow-input ()
"Refreshing unchanged pixels should leave the complete line intent residual."
(let (native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (&rest _) 'refreshed)))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create 1 '(inner))
(lambda (delta) (setq native-residual delta)))))
(should (equal (plist-get result :consumption) '((inner . 0))))
(should (= (plist-get result :ebox-consumed) 0))
(should (= (plist-get result :native-residual) 1))
(should (= native-residual 1))))))
(ert-deftest ebox-scroll-intent-pending-prefix-does-not-swallow-input ()
"An unfulfilled lazy-prefix request should leave its line intent residual."
(let (native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (&rest _) 'pending)))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create 3 '(inner parent))
(lambda (delta) (setq native-residual delta)))))
(should (equal (plist-get result :consumption)
'((inner . 0) (parent . 0))))
(should (= (plist-get result :ebox-consumed) 0))
(should (= (plist-get result :native-residual) 3))
(should (= native-residual 3))))))
(ert-deftest ebox-scroll-intent-anchor-routes-keyboard-and-wheel-independently ()
"Keyboard anchors at point while wheel anchors at the event position."
(cl-letf (((symbol-function 'ebox--scroll-region-ids-at-point)
(lambda () '(point-inner point-outer)))
((symbol-function 'ebox--wheel-region-ids)
(lambda (_event) '(wheel-inner wheel-outer))))
(should (equal (plist-get (ebox--scroll-intent-at-point 1) :chain)
'(point-inner point-outer)))
(should (equal (plist-get
(ebox--scroll-intent-at-event 'fake-event 1) :chain)
'(wheel-inner wheel-outer)))))
(ert-deftest ebox-scroll-intent-native-fallback-preserves-direction-and-count ()
"Keyboard residuals should call the matching native command exactly once."
(let (native-up native-down)
(cl-letf (((symbol-function 'ebox--scroll-region-ids-at-point)
(lambda () '(inner)))
((symbol-function 'ebox--scroll-region-by)
(lambda (_region-id delta &optional _budget)
(if (> delta 0) 1 -1)))
((symbol-function 'scroll-up)
(lambda (lines) (setq native-up lines)))
((symbol-function 'scroll-down)
(lambda (lines) (setq native-down lines))))
(ebox-scroll-down 3)
(ebox-scroll-up 3))
(should (= native-up 2))
(should (= native-down 2))))
(ert-deftest ebox-wheel-partial-residual-uses-exact-native-direction ()
"A partial wheel residual should not replay the complete wheel event."
(let (native-up native-down mwheel-called)
(cl-letf (((symbol-function 'event-start)
(lambda (_event) (list (selected-window) (point-min))))
((symbol-function 'scroll-up)
(lambda (lines) (setq native-up lines)))
((symbol-function 'scroll-down)
(lambda (lines) (setq native-down lines)))
((symbol-function 'mwheel-scroll)
(lambda (&rest _) (setq mwheel-called t))))
(ebox--wheel-native-residual 'event 2 5 nil)
(ebox--wheel-native-residual 'event -3 -5 nil))
(should (= native-up 2))
(should (= native-down 3))
(should-not mwheel-called)))
(ert-deftest ebox-wheel-smooth-boundary-defers-to-residual-chain ()
"Smooth mode should use the chain allocator when an owner cannot finish."
(let ((ebox-wheel-smooth-scroll t)
(capacity '((inner . 1) (parent . 2)))
native-residual
smooth-called)
(cl-letf (((symbol-function 'ebox--scroll-intent-at-event)
(lambda (_event delta)
(ebox--scroll-intent-create delta '(inner parent))))
((symbol-function 'ebox--first-scrollable-region)
(lambda (&rest _) 'inner))
((symbol-function 'ebox--scroll-region-can-consume-complete-p)
(lambda (&rest _) nil))
((symbol-function 'ebox--smooth-scroll-region-by)
(lambda (&rest _) (setq smooth-called t)))
((symbol-function 'ebox--scroll-region-by)
(lambda (region-id delta &optional _budget)
(let* ((available (alist-get region-id capacity))
(consumed (min delta available)))
(setf (alist-get region-id capacity) (- available consumed))
consumed)))
((symbol-function 'ebox--wheel-native-residual)
(lambda (_event residual _requested _arg)
(setq native-residual residual))))
(ebox--wheel-scroll 'event 5 nil t))
(should-not smooth-called)
(should (= native-residual 2))))
(ert-deftest ebox-scroll-mount-fallback-orders-equal-spans-deterministically ()
"Equal mounted spans should use semantic depth and a stable id tie-break."
(let ((ebox--scroll-global-state (make-hash-table :test 'equal)))
(puthash 'z-owner t ebox--scroll-global-state)
(puthash 'a-owner t ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox-surface-region-bounds)
(lambda (&rest _) '(1 . 5)))
((symbol-function 'ebox--scroll-region-semantic-depth)
(lambda (_region-id) 2)))
(should (equal (ebox--scroll-region-ids-in-mounts-at-pos 2)
'(a-owner z-owner))))))
(ert-deftest ebox-scroll-fast-report-uses-boolean-proof-flag ()
"Retained-window proof payload should not leak into the public report."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-build
'(box :id "root" :content "A\nB"
:height 1 :overflow scroll))))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id
(current-buffer) "#root")))
(should (= (ebox--scroll-region-by region-id 1) 1))
(let ((flag (plist-get (ebox-test--buffer-update-report)
:scroll-patch-fast-p)))
(should (memq flag '(nil t))))))))
(ert-deftest ebox-scroll-defaults-follow-one-line-native-buffer-scrolling ()
"Default wheel input should be one immediate document line."
(should (= ebox-wheel-scroll-step 1))
(should-not ebox-wheel-smooth-scroll))
(ert-deftest ebox-scroll-progress-requires-real-delta-or-pending ()
"Scroll progress is either a non-zero consumed line count or lazy pending."
(should (ebox--scroll-progress-p 1))
(should (ebox--scroll-progress-p -1))
(should (ebox--scroll-progress-p 'pending))
(should-not (ebox--scroll-progress-p 'refreshed))
(should-not (ebox--scroll-progress-p 0))
(should-not (ebox--scroll-progress-p nil)))

View File

@ -18,12 +18,12 @@
(defconst ebox-docs-test--active-files
'("Makefile" ".github/workflows/ci.yml"
"ebox.el" "ebox-cache.el" "ebox-style.el" "ebox-tree.el"
"ebox.el" "ebox-cache.el" "ebox-style.el" "ebox-child-range.el" "ebox-tree.el"
"ebox-measure.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-surface.el" "ebox-dsl.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-child-range-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"
@ -37,7 +37,7 @@
"Active Ebox files that the maintainer map must cover.")
(defconst ebox-docs-test--targets
'("check" "ci" "load" "compile" "core-tests" "grid-tests"
'("check" "ci" "load" "compile" "core-tests" "child-range-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"

View File

@ -37,6 +37,22 @@
(should (= (length lines) 1))
(should (= (ebox--string-pixel-width (car lines)) 120))))
(ert-deftest ebox-grid-stretch-children-fill-fractional-tracks ()
"Stretch-width children must reflow to their assigned fractional tracks."
(let* ((left (ebox-create :content "left" :width 'stretch))
(calls 0)
(entry-left (list :node left :column 1 :column-span 1))
(rendered (make-hash-table :test #'eq)))
(puthash left "left" rendered)
(cl-letf (((symbol-function 'ebox--render-with-cache)
(lambda (&rest _)
(cl-incf calls)
"track-width")))
(should (equal "track-width"
(ebox-grid--entry-source
entry-left rendered 100 '(:justify-items stretch))))
(should (= calls 1)))))
(ert-deftest ebox-grid-constrains-auto-width-children-to-track-size ()
"Auto-width grid children should render within their assigned track."
(let* ((ebox-viewport-width 120)

View File

@ -65,7 +65,8 @@
(should (commandp 'ebox-byte-compile))
(should
(equal ebox--compile-sources
'("ebox-cache.el" "ebox-style.el" "ebox-tree.el" "ebox-measure.el"
'("ebox-cache.el" "ebox-style.el" "ebox-child-range.el"
"ebox-tree.el" "ebox-measure.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-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el"
@ -163,7 +164,10 @@
ebox-build
ebox-buffer-mode
ebox-candidate-begin
ebox-child-range
ebox-candidate-replace
ebox-candidate-replace-range-ref
ebox-candidate-replace-root
ebox-candidate-replace-host-ref
ebox-clear-cache
ebox-column

View File

@ -27,6 +27,135 @@
(propertize "Open" 'keymap map 'mouse-face 'highlight
'help-echo "Open this item")))
(ert-deftest ebox-range-ref-present-p-is-a-read-only-boundary-query ()
"Expose mounted Range anchor presence without leaking runtime tables."
(let ((buffer (generate-new-buffer " *ebox-range-anchor-query*"))
(table (make-hash-table :test #'equal)))
(unwind-protect
(progn
(puthash 'probe 'record table)
(cl-letf (((symbol-function 'ebox--buffer-render-state)
(lambda (_buffer)
(list :range-ref-table table))))
(should (equal 'record
(ebox-range-ref-present-p buffer 'probe)))
(should-not (ebox-range-ref-present-p buffer 'missing))))
(kill-buffer buffer))))
(ert-deftest ebox-surface-context-initializes-from-live-window ()
"Use live window dimensions only when no explicit viewport is bound."
(let ((buffer (generate-new-buffer " *ebox-live-viewport-test*"))
(noninteractive nil)
(ebox-viewport-width nil) (ebox-viewport-height nil))
(unwind-protect
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _) nil))
((symbol-function 'window-body-width)
(lambda (_window pixelwise)
(should pixelwise)
777))
((symbol-function 'window-pixel-width)
(lambda (_window)
(error "Window outer width must not be sampled")))
((symbol-function 'window-body-height)
(lambda (_window) 31)))
(let ((values (ebox-surface--context-values buffer nil nil)))
(should (= 776 (plist-get values :viewport-width)))
(should (= 31 (plist-get values :viewport-height)))))
(kill-buffer buffer))))
(ert-deftest ebox-surface-context-keeps-headless-viewport-nil ()
"Keep viewport dimensions nil when BUFFER has no live window."
(let ((buffer (generate-new-buffer " *ebox-headless-viewport-test*"))
(ebox-viewport-width nil) (ebox-viewport-height nil))
(unwind-protect
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _) nil)))
(let ((values (ebox-surface--context-values buffer nil nil)))
(should-not (plist-get values :viewport-width))
(should-not (plist-get values :viewport-height))))
(kill-buffer buffer))))
(ert-deftest ebox-surface-context-rejects-half-width-pixelwise-report ()
"Use the outer pixel width when a GUI body query returns a half-width."
(let ((buffer (generate-new-buffer " *ebox-half-width-viewport-test*"))
(noninteractive nil)
(ebox-viewport-width nil) (ebox-viewport-height nil))
(unwind-protect
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _) nil))
((symbol-function 'window-body-width)
(lambda (_window pixelwise)
(should pixelwise)
715))
((symbol-function 'window-pixel-width)
(lambda (_window) 1430))
((symbol-function 'window-body-height)
(lambda (_window) 62)))
(let ((values (ebox-surface--context-values buffer nil nil)))
(should (= 1429 (plist-get values :viewport-width)))
(should (= 62 (plist-get values :viewport-height)))))
(kill-buffer buffer))))
(ert-deftest ebox-surface-context-prefers-selected-target-window ()
"Ignore stale cross-frame lookup when selected window shows BUFFER."
(let* ((buffer (generate-new-buffer " *ebox-selected-viewport-test*"))
(window (selected-window))
(old-buffer (window-buffer window))
(ebox-viewport-width nil) (ebox-viewport-height nil))
(unwind-protect
(progn
(set-window-buffer window buffer)
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _)
(error "Stale cross-frame lookup must not run")))
((symbol-function 'window-body-width)
(lambda (candidate pixelwise)
(should (eq candidate window))
(should pixelwise)
1400))
((symbol-function 'window-body-height)
(lambda (candidate)
(should (eq candidate window))
60)))
(let ((values (ebox-surface--context-values buffer nil nil)))
(should (= 1399 (plist-get values :viewport-width)))
(should (= 60 (plist-get values :viewport-height))))))
(when (window-live-p window) (set-window-buffer window old-buffer))
(kill-buffer buffer))))
(ert-deftest ebox-surface-context-prefers-current-frame-over-stale-frame ()
"Do not resize a live surface from an older client frame's window."
(let ((buffer (generate-new-buffer " *ebox-current-frame-viewport-test*"))
(noninteractive nil)
(ebox-viewport-width nil) (ebox-viewport-height nil))
(unwind-protect
(cl-letf (((symbol-function 'selected-window)
(lambda () 'selected-window))
((symbol-function 'window-live-p)
(lambda (_window) t))
((symbol-function 'window-buffer)
(lambda (_window) (get-buffer-create " *other-window*")))
((symbol-function 'selected-frame)
(lambda () 'current-frame))
((symbol-function 'get-buffer-window)
(lambda (_buffer frame)
(if (eq frame 'current-frame)
'current-frame-window
'stale-frame-window)))
((symbol-function 'window-body-width)
(lambda (window pixelwise)
(should pixelwise)
(if (eq window 'current-frame-window) 901 333)))
((symbol-function 'window-body-height)
(lambda (_window) 31)))
(let ((values (ebox-surface--context-values buffer nil nil)))
(should (= 900 (plist-get values :viewport-width)))
(should (= 31 (plist-get values :viewport-height)))))
(kill-buffer buffer)
(when (get-buffer " *other-window*")
(kill-buffer " *other-window*")))))
(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
@ -223,6 +352,52 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(ebox--render-owned-text-value-p
'face (get-text-property 0 'face rendered)))))
(ert-deftest ebox-surface-paint-origin-captures-before-composition ()
"Capture caller face once before Ebox adds a paint contribution."
(let* ((caller-face '(:weight bold))
(rendered (propertize "x" 'face caller-face))
(ebox--paint-origin-capture-p t))
(ebox--add-render-face! rendered 0 1 '(:foreground "red") t)
(let ((origin (get-text-property 0 ebox--paint-origin-property rendered)))
(should (ebox--paint-origin-p origin))
(should (equal caller-face
(ebox--paint-origin-baseline origin)))
(should (equal (list caller-face '(:foreground "red"))
(get-text-property 0 'face rendered))))
;; A nested contribution must not replace the original caller baseline.
(ebox--add-render-face! rendered 0 1 '(:background "blue") t)
(should (equal caller-face
(ebox--paint-origin-baseline
(get-text-property 0 ebox--paint-origin-property
rendered))))))
(ert-deftest ebox-surface-paint-address-is-semantic-and-ordered ()
"Paint ledger addresses use owner facts, content index, and local order."
(let ((rendered (copy-sequence "ab")))
(put-text-property 0 1 'ebox-content-owner 7 rendered)
(put-text-property 0 1 'ebox-content-idx 3 rendered)
(put-text-property 0 1 'ebox-content-owners '(7 2) rendered)
(put-text-property 0 1 'face 'bold rendered)
(put-text-property 1 2 'ebox-content-owner 7 rendered)
(put-text-property 1 2 'ebox-content-idx 3 rendered)
(put-text-property 1 2 'ebox-content-owners '(7 2) rendered)
(put-text-property 1 2 'face 'italic rendered)
(let* ((origin (ebox--paint-origin-create :baseline '(:weight bold)))
(_ (put-text-property 0 2 ebox--paint-origin-property
origin rendered))
(fragments (ebox-surface--rendered-fragments rendered))
(first (car fragments))
(second (cadr fragments))
(address (plist-get first :paint-address)))
(should (= (length fragments) 2))
(should (equal (plist-get address :content-owner) 7))
(should (= (plist-get address :content-index) 3))
(should (= (plist-get address :ordinal) 0))
(should (= (plist-get (plist-get second :paint-address) :ordinal) 1))
(should (equal (plist-get first :face-baseline) '(:weight bold)))
(should (plist-get first :face-baseline-known-p))
(should-not (get-text-property 0 ebox--paint-origin-property 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"))
@ -1110,15 +1285,19 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(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."
"Nested scroll state and visible overflow must not enter retained reflow."
(dolist (fixture
(list
(cons 'scroll
(cons 'nested-scroll
(lambda ()
(ebox-create :content "zero\none\ntwo"
(ebox-create :content "outer"
:width '(viewport)
:height 2
:overflow 'scroll)))
:overflow 'scroll
:ebox-content-node
(ebox-create :content "zero\none\ntwo"
:height 2
:overflow 'scroll))))
(cons 'visible-overflow
(lambda ()
(ebox-create :content "one\ntwo"
@ -1147,6 +1326,47 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-viewport-reflow-retains-viewport-dependent-root-scroll ()
"A sole root scroll owner may reflow its own viewport-dependent content."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-viewport-root-scroll*"))
(ensured-node-count 0)
(original-ensure (symbol-function 'ebox-surface--ensure-node-tree)))
(unwind-protect
(let ((ebox-viewport-width 160)
(ebox-viewport-height 2)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(ebox-render-to-buffer
buffer
(ebox-create :key 'root-scroll
:content "zero\none\ntwo\nthree"
:width '(viewport)
:height '(viewport-height)
:overflow 'scroll))
(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 2))
(let* ((state (ebox--buffer-render-state buffer))
(report (ebox-buffer-update-report buffer))
(expected
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(ebox-render (plist-get state :root-node))))
(actual
(with-current-buffer buffer
(buffer-substring (point-min) (point-max)))))
(should (= ensured-node-count 0))
(should (eq (plist-get report :projection-kind)
'viewport-reflow))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (equal-including-properties actual expected))))
(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
@ -1400,6 +1620,200 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-style-theme-delta-reuses-cascade-facts ()
"Copy a paint-only Theme delta without rerunning ECSS cascade computation."
(let* ((subject (ecss-subject-create :type "box"))
(old-declarations
'(ebox/width (100) ebox/color "#252A2E"
ebox/background-color "#F8F5EE"))
(new-declarations
'(ebox/width (100) ebox/color "#F2EEE4"
ebox/background-color "#1B1F20"))
(old-style
(ebox-style-compute-subject subject old-declarations))
(delta
(ebox-style--theme-delta-computed
old-style old-declarations new-declarations)))
(should (ecss-computed-style-p delta))
(should-not (eq old-style delta))
(should (equal "#F2EEE4"
(ecss-computed-style-value delta 'ebox/color)))
(should (equal "#1B1F20"
(ecss-computed-style-value
delta 'ebox/background-color)))
(should (equal (ecss-computed-style-value old-style 'ebox/width)
(ecss-computed-style-value delta 'ebox/width)))
(should-not
(ebox-style--theme-delta-computed
old-style old-declarations
(plist-put (copy-sequence new-declarations) 'ebox/width '(120))))))
(ert-deftest ebox-style-theme-delta-propagates-inherited-color ()
"Copy a proven inherited Theme color without rerunning ECSS.
The child has no explicit color declaration; only its static parent color
changes. Geometry and non-inherited computed values must remain identical."
(let* ((parent (ecss-subject-create :type "box"))
(child (ecss-subject-create :type "box" :parent parent))
(parent-old
(ebox-style-compute-subject parent '(ebox/color "#111111")))
(parent-new
(ebox-style-compute-subject parent '(ebox/color "#222222")))
(declarations '(ebox/width (100) ebox/background-color "#eeeeee"))
(style
(ebox-style-compute-subject child declarations parent-old))
(delta
(ebox-style--theme-inherited-delta-computed
style declarations parent-old parent-new)))
(should (ecss-computed-style-p delta))
(should (equal "#222222"
(ecss-computed-style-value delta 'ebox/color)))
(should (equal (ecss-computed-style-value style 'ebox/width)
(ecss-computed-style-value delta 'ebox/width)))
(should (equal (ecss-computed-style-value style 'ebox/background-color)
(ecss-computed-style-value
delta 'ebox/background-color)))))
(ert-deftest ebox-style-theme-parent-delta-reuses-explicit-child-style ()
"Reuse an explicit child style when only its static parent Theme changes."
(let* ((parent (ecss-subject-create :type "box"))
(child (ecss-subject-create :type "box" :parent parent))
(parent-old
(ebox-style-compute-subject parent '(ebox/color "#111111")))
(parent-new
(ebox-style-compute-subject parent '(ebox/color "#222222")))
(declarations '(ebox/color "#ffffff" ebox/width (100)))
(style
(ebox-style-compute-subject child declarations parent-old))
(delta
(ebox-style--theme-parent-delta-computed
style declarations parent-old parent-new)))
(should (ecss-computed-style-p delta))
(should (equal (ecss-computed-style-values style)
(ecss-computed-style-values delta)))
(let ((parent-font-new
(ebox-style-compute-subject
parent '(ebox/color "#222222" ebox/font-height 2.0))))
(should-not
(ebox-style--theme-parent-delta-computed
style declarations parent-old parent-font-new)))))
(ert-deftest ebox-tree-source-signature-ignores-derived-width-proof ()
"A layout-derived exact-width flag must not dirty declarative content."
(let* ((old (ebox-create :content "Stable" :width '(100)))
(new (copy-tree old)))
(plist-put old :ebox-content-width-exact-p nil)
(plist-put new :ebox-content-width-exact-p t)
(should (equal (ebox-tree-node-local-source-signature old)
(ebox-tree-node-local-source-signature new)))
(should-not (memq :ebox-content-width-exact-p
(ebox-tree-node-local-changed-keys old new)))))
(ert-deftest ebox-tree-grid-source-signature-canonicalizes-layout-aliases ()
"Equivalent Grid gap/paint aliases must not become geometry dirtiness."
(let ((old (list :ebox-type 'grid
:raw-props '(:width stretch
:grid-template-columns (1fr 1fr)
:grid-row-gap 1 :grid-column-gap (12)
:color "#252A2E" :background-color "#F8F5EE")))
(new (list :ebox-type 'grid
:raw-props '(:width stretch
:grid-template-columns (1fr 1fr)
:gap (1 (12))
:color "#F2EEE4" :bgcolor "#1B1F20"))))
(should-not (memq :props (ebox-tree-node-local-changed-keys old new)))
(should-not (memq :raw-props (ebox-tree-node-local-changed-keys old new)))))
(ert-deftest ebox-tree-flex-source-signature-canonicalizes-layout-aliases ()
"Equivalent Flex gap aliases must not become geometry dirtiness."
(let ((old (list :ebox-type 'flex
:raw-props '(:width stretch
:row-gap 1 :column-gap (12)
:padding-block-start 0
:padding-inline-end 2
:padding-block-end 0
:padding-inline-start 2
:border-top-width (1)
:border-right-width (1)
:border-bottom-width (1)
:border-left-width (1)
:border-top-style solid
:border-right-style solid
:border-bottom-style solid
:border-left-style solid
:border-top-color "#687386"
:border-right-color "#687386"
:border-bottom-color "#687386"
:border-left-color "#687386"
:align-items center
:color "#252A2E"
:background-color "#F8F5EE")))
(new (list :ebox-type 'flex
:raw-props '(:width stretch
:gap (1 (12))
:padding (0 2)
:border ((1) solid "#687386")
:align-items center
:color "#F2EEE4"
:bgcolor "#1B1F20"))))
(should-not (memq :props (ebox-tree-node-local-changed-keys old new)))
(should-not (memq :raw-props (ebox-tree-node-local-changed-keys old new)))))
(ert-deftest ebox-style-theme-delta-rejects-inherited-parent-change ()
"Do not reuse a child style when its inherited parent fingerprint changes."
(let* ((parent (ecss-subject-create :type "box"))
(child (ecss-subject-create :type "box" :parent parent))
(parent-old
(ebox-style-compute-subject
parent '(ebox/font-height 1.0)))
(parent-new
(ebox-style-compute-subject
parent '(ebox/font-height 2.0)))
(declarations '(ebox/color "#ffffff"))
(style
(ebox-style-compute-subject child declarations parent-old)))
(should-not
(ebox-style--theme-delta-computed
style declarations declarations parent-old parent-new))))
(ert-deftest ebox-style-theme-delta-rejects-parent-custom-property-change ()
"Do not reuse a Theme delta when a parent custom property changes."
(let* ((parent (ecss-subject-create :type "box"))
(child (ecss-subject-create :type "box" :parent parent))
(parent-old
(ebox-style-compute-subject parent '(--theme "#ffffff")))
(parent-new
(ebox-style-compute-subject parent '(--theme "#000000")))
(old-declarations
'(ebox/color "#ffffff" ebox/background-color "#ffffff"))
(new-declarations
'(ebox/color "#eeeeee" ebox/background-color "#eeeeee"))
(style
(ebox-style-compute-subject child old-declarations parent-old)))
(should-not
(ebox-style--theme-delta-computed
style old-declarations new-declarations parent-old parent-new))))
(ert-deftest ebox-style-paint-declarations-equivalent-includes-border-colors ()
"Pressed/hover paint changes must not invalidate layout style closure."
(should
(ebox-style--paint-declarations-equivalent-p
'(ebox/color "#ffffff"
ebox/background-color "#2f6b43"
ebox/border-top-color "#2f6b43"
ebox/border-right-color "#2f6b43"
ebox/border-bottom-color "#2f6b43"
ebox/border-left-color "#2f6b43")
'(ebox/color "#ffffff"
ebox/background-color "#1e5a56"
ebox/border-top-color "#174a47"
ebox/border-right-color "#174a47"
ebox/border-bottom-color "#174a47"
ebox/border-left-color "#174a47")))
(should-not
(ebox-style--paint-declarations-equivalent-p
'(ebox/color "#ffffff" ebox/width max-content)
'(ebox/color "#ffffff" ebox/width stretch))))
(ert-deftest ebox-render-to-buffer-reuses-one-source-across-buffers ()
"The public mount path should never transfer ownership of its source tree."
(ebox-surface-test--reset-render-state)
@ -1601,6 +2015,30 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-finalization-contains-each-error-and-quit ()
"Post-TP scroll actions report failures without skipping later actions."
(let ((ebox--scroll-global-state (make-hash-table :test 'equal))
(ebox--smooth-scroll-state-table (make-hash-table :test 'equal))
trace diagnostics)
(cl-letf (((symbol-function 'ebox--scroll-cancel-idle-prefetch)
(lambda (region-id)
(push (list 'cancel region-id) trace)
(error "cancel failure")))
((symbol-function 'ebox--smooth-scroll-stop)
(lambda (region-id)
(push (list 'stop region-id) trace)
(signal 'quit nil))))
(setq diagnostics
(ebox-incremental--finalize-declarative-scroll-publication
'(one two))))
(should (equal (nreverse trace)
'((cancel one) (stop one) (cancel two) (stop two))))
(should (= (length diagnostics) 4))
(should (equal (mapcar (lambda (entry) (plist-get entry :action))
diagnostics)
'(cancel-prefetch stop-smooth-scroll
cancel-prefetch stop-smooth-scroll)))))
(ert-deftest ebox-region-handles-are-surface-scoped ()
"One logical id should resolve to distinct handles on independent surfaces."
(ebox-surface-test--reset-render-state)
@ -1656,6 +2094,25 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-region-update-preserves-point-after-incremental-publication ()
"An incremental Ebox content update must not leave point at its patch."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-point-preservation*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :id "target" :content "before target after"
:width '(200)))
(with-current-buffer buffer (goto-char 4))
(let ((point-before (with-current-buffer buffer (point))))
(ebox-region-update (ebox-region-resolve buffer "target")
:content "before changed-target after")
(should (= point-before
(with-current-buffer buffer (point))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-region-update-rejects-process-global-region-ids ()
"Direct updates should require a surface-scoped region handle."
(ebox-surface-test--reset-render-state)
@ -1761,6 +2218,171 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-patch-reuses-visible-lines-and-retains-region-index ()
"A chrome-free root scroll patch must avoid layout and keep all regions indexed."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-scroll-visible-window*")))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(full-renders 0))
(ebox-render-to-buffer
buffer
(ebox-create :id "scroll" :content
(mapconcat #'number-to-string (number-sequence 0 31) "\n")
:width '(120) :height 2 :overflow 'scroll))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(region-id
(plist-get
(car (ebox-selector-query-buffer buffer "#scroll"))
:region-id))
(old-state (tp-surface-client-state surface)))
;; Materialize this tiny fixture so the direct visible-window
;; proof is exercised rather than the lazy-prefix fallback.
(let ((scroll-state
(ebox--scroll-state-materialize-lines
region-id (ebox--scroll-get-state region-id))))
(puthash region-id scroll-state ebox--scroll-global-state))
(let ((owner-plan-calls 0)
(original-owner-plan
(symbol-function
'ebox-incremental--layout-owner-plan)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(cl-incf full-renders)
(error "full root render used by scroll patch")))
((symbol-function
'ebox-incremental--layout-owner-plan)
(lambda (&rest args)
(cl-incf owner-plan-calls)
(apply original-owner-plan args))))
(should (= (ebox--scroll-region-by region-id 1 1) 1)))
(should (= owner-plan-calls 0)))
(let* ((report (ebox-buffer-update-report buffer))
(state (tp-surface-client-state surface))
(region-table (plist-get state :region-box-table))
(region-set (plist-get state :region-id-set))
(text (with-current-buffer buffer
(buffer-substring-no-properties
(point-min) (point-max)))))
(should (= full-renders 0))
(should (eq (plist-get report :projection-kind) 'scroll-patch))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (= (hash-table-count region-table)
(hash-table-count region-set)))
(maphash
(lambda (id node)
(should (eq node
(gethash (gethash id
(plist-get state
:region-node-table))
(plist-get state :node-table)))))
region-table)
(should (string-match-p "1" text))
(should-not (string-match-p "^0$" text))
(should (equal (plist-get (plist-get old-state :root-node)
:node-id)
(plist-get (plist-get state :root-node)
:node-id)))
(should (eq (gethash (plist-get (plist-get state :root-node)
:node-id)
(plist-get state
:surface-node-object-table))
(gethash (plist-get (plist-get old-state :root-node)
:node-id)
(plist-get old-state
:surface-node-object-table))))))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-scroll-patch-rolls-back-at-tp-client-state-publication ()
"A scroll patch failure after TP client-state must restore the old generation."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-scroll-patch-rollback*")))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(ebox-render-to-buffer
buffer
(ebox-create :id "scroll" :content "zero\none\ntwo\nthree"
:width '(120) :height 2 :overflow 'scroll))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(region-id
(plist-get
(car (ebox-selector-query-buffer buffer "#scroll"))
:region-id))
(state (tp-surface-client-state surface))
(revision (tp-surface-revision surface))
(contents (with-current-buffer buffer
(buffer-substring (point-min) (point-max))))
(old-region-table (plist-get state :region-box-table)))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state)
(error "reject scroll client-state publication")))))
(should-error (ebox--scroll-region-by region-id 1 1)))
(should (= (tp-surface-revision surface) revision))
(should (eq (tp-surface-client-state surface) state))
(should (eq (plist-get state :region-box-table)
old-region-table))
(should (= (plist-get (ebox-scroll-state region-id)
:scroll-offset)
0))
(should (equal-including-properties
(with-current-buffer buffer
(buffer-substring (point-min) (point-max)))
contents))
(should (= (ebox--scroll-region-by region-id 1 1) 1))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-patch-reuses-incomplete-flex-visible-window ()
"A lazy Flex prefix may use retained output when its visible slice is ready.
The prefix need not be fully materialized; a scroll step that remains inside
the staged rendered window must not rerun the Flex wrapper layout."
(let* ((children
(cl-loop for index below 80
collect
(ebox-create :key (intern (format "flex-cell-%03d" index))
:content (format "Cell %03d" index)
:width '(80) :height 1)))
(flex (apply #'ebox-flex :flex-flow '(row wrap)
:width '(180) :column-gap '(8) :row-gap 1
children))
(root (ebox-create :key 'scroll-root :width '(180) :height 6
:overflow 'scroll :ebox-content-node flex))
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-incomplete-flex-scroll*")
root))
(state (ebox--buffer-render-state buffer))
(region-id (car (plist-get state :scroll-region-ids)))
(scroll-state (gethash region-id
(plist-get state :scroll-state-table))))
(unwind-protect
(progn
(should region-id)
(should scroll-state)
(should-not (plist-get scroll-state :content-lines-complete-p))
(should (ebox--scroll-state-rendered-visible-window scroll-state))
(should (ebox--scroll-state-retained-window-ready-p scroll-state))
(with-current-buffer buffer
(ebox--scroll-region-by region-id 1 1))
(let ((report (ebox-buffer-update-report buffer))
(current (ebox--buffer-render-state buffer)))
(should (eq (plist-get report :projection-kind) 'scroll-patch))
(should (plist-get report :scroll-patch-fast-p))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (= (hash-table-count
(plist-get current :region-id-set))
(hash-table-count
(plist-get current :region-box-table)))))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-scroll-update-rejects-a-runtime-replaced-by-its-hook ()
"A stale scroll candidate must not overwrite a hook publication."
(ebox-surface-test--reset-render-state)
@ -1801,6 +2423,84 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-native-buffer-scroll-is-transactional-and-line-oriented ()
"An eligible root scroll uses the window one line at a time.
The test stubs the GUI window boundary so batch ERT can exercise the same
participant and rollback contract without creating a real frame."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-native-scroll*")))
(unwind-protect
(let ((ebox-viewport-width 120)
(ebox-viewport-height 2)
(noninteractive nil)
(ebox-runtime-idle-prewarm nil)
(window-start 1)
(window-point 1))
(ebox-render-to-buffer
buffer
(ebox-create :id "native-scroll" :content "zero\none\ntwo\nthree"
:width '(120) :height 2 :overflow 'scroll))
(let* ((state (ebox--buffer-render-state buffer))
(region-id (car (plist-get state :scroll-region-ids)))
(scroll-state
(gethash region-id (plist-get state :scroll-state-table))))
(plist-put state :native-buffer-scroll-p t)
(plist-put scroll-state :content-lines-complete-p t)
(plist-put scroll-state :rendered-content-lines
'("zero" "one" "two" "three"))
(plist-put scroll-state :content-height 2)
(puthash region-id scroll-state ebox--scroll-global-state)
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _) 'ebox-test-window))
((symbol-function 'window-live-p)
(lambda (&rest _) t))
((symbol-function 'window-start)
(lambda (&rest _) window-start))
((symbol-function 'window-point)
(lambda (&rest _) window-point))
((symbol-function 'set-window-start)
(lambda (_window position &rest _)
(setq window-start position)))
((symbol-function 'set-window-point)
(lambda (_window position)
(setq window-point position)))
((symbol-function
'ebox--native-buffer-scroll-root-proof-p)
(lambda (&rest _) t)))
(should (= (ebox--native-buffer-scroll-by buffer region-id 1)
1))
(should (= (plist-get scroll-state :scroll-offset) 1))
(should (> window-start 1))
(should (equal (plist-get state :last-update-report)
(ebox-buffer-update-report buffer)))
(should (eq (plist-get (ebox-buffer-update-report buffer)
:projection-kind)
'native-buffer-scroll))
;; The native path is a presentation-only transaction, but it
;; must still restore both window and Ebox state if a window
;; primitive fails halfway through the move.
(setq window-start 1
window-point 1)
(plist-put scroll-state :scroll-offset 0)
(ebox-put (plist-get scroll-state :box) :scroll-offset 0)
(plist-put state :last-update-report nil)
(let ((fail-once t))
(cl-letf (((symbol-function 'set-window-point)
(lambda (_window position)
(if fail-once
(progn
(setq fail-once nil)
(error "native window point failure"))
(setq window-point position)))))
(should-error
(ebox--native-buffer-scroll-by buffer region-id 1))))
(should (= window-start 1))
(should (= window-point 1))
(should (= (plist-get scroll-state :scroll-offset) 0))
(should-not (plist-get state :last-update-report))))))
(when (buffer-live-p buffer)
(kill-buffer buffer))))
(provide 'ebox-surface-tests)
;;; ebox-surface-tests.el ends here