refactor(ebox): publish roots through TP surfaces

Move ephemeral rendering, initial buffer mounts, declarative commits, and opaque handle updates onto retained TP surfaces while keeping Ebox layout planning and runtime indexes transactionally synchronized.

Verified with: make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
This commit is contained in:
Kinneyzhang 2026-08-06 05:49:22 +08:00
parent fad54d7fb1
commit cdf841232a
18 changed files with 1337 additions and 323 deletions

View File

@ -21,7 +21,7 @@ Install TP first, then install Ebox through a package manager so its declared de
:bgcolor "#F4F6FB")))
```
The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` returns propertized text; `ebox-render-to-buffer` publishes it into a buffer; and `ebox-commit` updates an existing runtime from a fresh root tree.
The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` materializes propertized text through an ephemeral TP surface; `ebox-render-to-buffer` mounts a retained TP surface; and `ebox-commit` atomically updates that surface from a fresh root tree. Ebox copies declarative input before assigning runtime identity, so one source tree may be mounted in multiple buffers without transferring ownership.
## What belongs here
@ -40,8 +40,8 @@ ETAF belongs in the sibling package. Install it separately when you need the uni
| `ebox.el` | Public facade and package entry point. |
| `ebox-style.el`, `ebox-tree.el`, `ebox-measure.el` | Style, tree, and measurement models. |
| `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Formatting contexts and layout algorithms. |
| `ebox-surface.el` | Pure Ebox runtime/layout projection into generic TP surface plans. |
| `ebox-buffer-backend.el`, `ebox-incremental.el` | Buffer publication and incremental updates. |
| `ebox-surface.el` | Ebox candidate projection plus TP surface mount/update and atomic Ebox runtime-state participation. |
| `ebox-buffer-backend.el`, `ebox-incremental.el` | Layout dirty planning, marker indexes, and specialized local update paths. |
| `ebox-dsl.el`, `ebox-selector.el` | Standalone DSL and tree/runtime queries. |
| `ebox-native-reflow.el`, `native/` | Optional native reflow boundary. |
| `examples/`, `tests/` | Ebox-only examples and regression tests. |

View File

@ -24,9 +24,9 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `ebox-layout.el` | Box, row, column, stack, concatenation, spacer, wrapping, and common formatting context. |
| `ebox-flex.el` | Flex normalization, lines, free-space distribution, and flex rendering. |
| `ebox-grid.el` | Tracks, implicit tracks, fractions, minmax/repeat, gap, placement, span, and alignment. |
| `ebox-surface.el` | Pure candidate identity and projection from Ebox layout output to generic TP surface plans. |
| `ebox-buffer-backend.el` | Text properties, display spaces/borders, markers, extents, replacement, and buffer mutation. |
| `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, atomic publication, and reports. |
| `ebox-surface.el` | Candidate identity, projection to TP surface plans, retained mount/update, and the rollback-capable Ebox runtime-state participant. |
| `ebox-buffer-backend.el` | Text properties, display spaces/borders, markers, extents, and specialized local patch primitives. |
| `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, pure commit preparation, and reports. |
| `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. |
| `ebox-selector.el` | CSS-like queries over trees and runtime handles. |
| `ebox-native-reflow.el` | Optional native module loading/build commands, ABI checks, bounded sessions, and Elisp fallback. |
@ -40,15 +40,18 @@ The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/e
The normal data flow is:
```text
Source Tree
Caller-owned Source Tree
-> Surface-owned Runtime Copy
-> TP Candidate Objects
-> Element Tree
-> Computed Style
-> Box/Formatting Context
-> Measurement + Render Context
-> Layout Fragment/Snapshot
-> Pure TP Surface Plan
-> Dirty/Patch Plan
-> Emacs Buffer Backend
-> Ebox Dirty/Patch Plan
-> TP Surface Plan
-> TP Atomic Publication
-> Ebox Runtime-State Participant
```
| Model | Owner | Must not own |
@ -58,22 +61,26 @@ Source Tree
| Measurement | `ebox-measure.el` | Application state or dirty policy. |
| Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. |
| Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. |
| Surface Projection | `ebox-surface.el` | Buffer mutation or Ebox layout decisions. |
| Dirty/Patch | `ebox-incremental.el` | Raw measurement or direct buffer edits. |
| Buffer Backend | `ebox-buffer-backend.el` | Style semantics or application state. |
| Surface Projection and Full-Root Publication | `ebox-surface.el` plus public TP surface APIs | Ebox layout decisions or generic diff execution. |
| Dirty/Patch Semantics | `ebox-incremental.el` | Raw measurement or TP buffer writes. |
| Generic Surface Diff/Commit | TP | Ebox geometry, dirty policy, or application state. |
| Specialized Local Backend | `ebox-buffer-backend.el` | Full-root mount/commit ownership, style semantics, or application state. |
## Invariants
- Public Ebox nodes are data; `ebox--*` names are private.
- A one-element horizontal list such as `'(420)` denotes pixels; ordinary horizontal numbers denote character columns.
- `ebox-render` does not publish to a buffer; `ebox-render-to-buffer` owns initial publication; `ebox-commit` owns declarative replacement.
- `ebox-render` uses an ephemeral TP surface and does not publish to a buffer; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-commit` prepares Ebox semantics and updates that surface through TP.
- Declarative input remains caller-owned. Live mount/commit assigns identity only on a surface-owned copy, so the same source can back multiple buffers.
- Logical `:id` values resolve through `ebox-region-resolve` to opaque surface-scoped handles. Handles, not source-tree numeric ids, distinguish the same logical region mounted in different buffers.
- A failed candidate leaves the previous buffer, runtime identity, and report intact.
- Keys are local to siblings; visible strings are never used as identity.
- `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`.
- Buffer coordinates belong to the generation that produced them and must be refreshed after mutation.
- Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness.
- Loading Ebox never builds or installs the optional Rust module.
- The Phase 7 projector assigns TP candidate identity before layout, emits plans containing no markers, positions, buffers, or TP runtime handles, and is byte-for-byte and property-for-property equivalent to `ebox-render`. Public live entry points still use the existing Ebox backend until the next cutover phase.
- Phase 8 is active: `ebox-render-to-buffer` and declarative `ebox-commit` use TP as their only live publication boundary, while Ebox runtime state joins the same rollback-capable transaction. Existing specialized numeric region, selector, viewport, and scroll update publishers remain until the local-update cutover; they must not become an alternate full-root path.
- Successful full-root reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP operation counts, surface revision, and retained-object reconciliation counts.
## Grid contract

View File

@ -24,9 +24,9 @@
| `ebox-layout.el` | box、row、column、stack、concat、spacer、换行和通用 formatting context。 |
| `ebox-flex.el` | flex 归一化、line、剩余空间分配和 flex 渲染。 |
| `ebox-grid.el` | 轨道、隐式轨道、分数、minmax/repeat、gap、placement、span 和对齐。 |
| `ebox-surface.el` | Ebox 候选 identity以及从布局输出到通用 TP surface plan 的纯投影。 |
| `ebox-buffer-backend.el` | text property、display space/border、marker、extent、替换和 buffer 变更。 |
| `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、原子发布和报告。 |
| `ebox-surface.el` | 候选 identity、向 TP surface plan 的投影、retained mount/update以及支持 rollback 的 Ebox runtime-state participant。 |
| `ebox-buffer-backend.el` | text property、display space/border、marker、extent 与专用局部 patch primitive。 |
| `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、纯 commit 准备和报告。 |
| `ebox-dsl.el` | 数据型 `.ebox` form以及向公共节点的 lowering。 |
| `ebox-selector.el` | 对树和 runtime handle 的 CSS-like 查询。 |
| `ebox-native-reflow.el` | 可选 native 模块加载/构建、ABI 校验、受限 session 和 Elisp fallback。 |
@ -40,15 +40,18 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
正常数据流是:
```text
Source Tree
调用者拥有的 Source Tree
-> Surface 拥有的 Runtime Copy
-> TP Candidate Objects
-> Element Tree
-> Computed Style
-> Box/Formatting Context
-> Measurement + Render Context
-> Layout Fragment/Snapshot
-> 纯 TP Surface Plan
-> Dirty/Patch Plan
-> Emacs Buffer Backend
-> Ebox Dirty/Patch Plan
-> TP Surface Plan
-> TP 原子发布
-> Ebox Runtime-State Participant
```
| 模型 | Owner | 不得拥有 |
@ -58,22 +61,26 @@ Source Tree
| Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 |
| Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 |
| Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 |
| Surface Projection | `ebox-surface.el` | Buffer 变更或 Ebox 布局决策。 |
| Dirty/Patch | `ebox-incremental.el` | 原始测量或直接 buffer 编辑。 |
| Buffer Backend | `ebox-buffer-backend.el` | 样式语义或应用状态。 |
| Surface 投影与整根发布 | `ebox-surface.el` 加公共 TP surface API | Ebox 布局决策或通用 diff 执行。 |
| Dirty/Patch 语义 | `ebox-incremental.el` | 原始测量或 TP buffer 写入。 |
| 通用 Surface Diff/Commit | TP | Ebox 几何、dirty 策略或应用状态。 |
| 专用局部 Backend | `ebox-buffer-backend.el` | 整根 mount/commit 所有权、样式语义或应用状态。 |
## 不变量
- 公共 Ebox 节点是数据;`ebox--*` 名称是私有实现。
- `'(420)` 这样的单元素横向 list 表示像素;普通横向数字表示字符列。
- `ebox-render` 不发布到 buffer`ebox-render-to-buffer` 负责首次发布;`ebox-commit` 负责声明式替换。
- `ebox-render` 使用临时 TP surface不发布到 buffer`ebox-render-to-buffer` 挂载 retained TP surface`ebox-commit` 准备 Ebox 语义后通过 TP 更新该 surface。
- 声明式输入始终由调用者拥有。live mount/commit 只在 surface-owned copy 上分配 identity因此同一 source 可以挂载到多个 buffer。
- 逻辑 `:id` 通过 `ebox-region-resolve` 解析为不透明、surface-scoped 的 handle同一逻辑 region 挂载到多个 buffer 时,由 handle 而不是 source-tree 数字 id 区分。
- 候选失败时必须保留之前的 buffer、runtime identity 和报告。
- Key 只在兄弟节点中有效;不能用可见字符串作为 identity。
- `owner-rerender` 范围大于 `span-patch``span-patch` 大于 `paint-patch`
- Buffer 坐标属于生成它的 generation变更后必须重新获取。
- Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。
- 加载 Ebox 不会构建或安装可选 Rust 模块。
- Phase 7 projector 在布局前分配 TP candidate identity输出不含 marker、position、buffer 或 TP runtime handle 的 plan并与 `ebox-render` 保持逐字节、逐属性等价。公开 live 入口在下一阶段切换前仍使用现有 Ebox backend。
- 当前已进入 Phase 8`ebox-render-to-buffer` 与声明式 `ebox-commit` 只通过 TP 这一条 live 发布边界Ebox runtime state 在同一个可 rollback transaction 中参与提交。现有的数字 region、selector、viewport 与 scroll 专用局部 publisher 暂时保留到局部更新切换阶段,但不得成为第二条整根发布路径。
- 成功的整根报告会保留 Ebox 语义策略与 planned publication scope再加入 `:publication-scope tp-surface`、TP operation 数、surface revision 和 retained-object reconciliation 统计。
## Grid 合同

View File

@ -7,8 +7,10 @@ This document defines the low-level publication contract. It is independent of E
1. The caller owns the fresh source tree.
2. `ebox-tree.el` owns identity, keys, traversal, and snapshots.
3. `ebox-style.el`, `ebox-measure.el`, and the layout modules own normalization, measurement, and geometry.
4. `ebox-incremental.el` owns dirty classification, patch planning, candidate publication, and update reports.
5. `ebox-buffer-backend.el` is the only owner of text-property and buffer mutation.
4. `ebox-incremental.el` owns dirty classification, patch planning, pure declarative commit preparation, and Ebox report semantics.
5. `ebox-surface.el` projects retained candidates and joins Ebox runtime-state publication to the TP transaction.
6. TP owns generic retained-surface reconciliation, buffer diff execution, revision changes, and rollback for initial mount and declarative full-root commit.
7. `ebox-buffer-backend.el` owns Ebox marker/index primitives and the specialized local numeric region, viewport, and scroll publishers that have not yet crossed the local-update cutover.
No layer may infer application state from visible buffer text. No public caller may mutate a published tree in place.
@ -17,23 +19,25 @@ No layer may infer application state from visible buffer text. No public caller
```text
fresh root
-> validate and normalize
-> reconcile keys and identity
-> copy into surface-owned candidate state
-> reconcile TP objects, keys, and Ebox identity
-> measure/layout candidate
-> classify dirty owners
-> render candidate spans
-> publish atomically
-> refresh snapshots and report
-> produce TP surface plan plus Ebox report base
-> TP prepares and atomically publishes buffer changes
-> Ebox transaction participant publishes matching runtime indexes/report
-> retire superseded markers and timers after success
```
The candidate is either fully published or discarded. A failed candidate preserves the previous buffer text, properties, runtime identity, scroll state, and last successful report.
The candidate is either fully published or discarded. A failed render, TP write, Ebox participant, or publication callback preserves the previous buffer text, properties, TP revision, Ebox runtime identity, scroll state, and last successful report. If the target buffer is killed during publication, teardown is authoritative and rollback never resurrects it.
## Patch order
The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. A patch must not silently widen its scope; the report records the selected strategy, dirty keys, owners, and patch operations.
The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. A patch must not silently widen its semantic scope. Declarative full-root reports preserve that Ebox strategy and planned scope while separately recording TP's actual surface operations and revision.
## Identity and coordinates
Keys are local to a sibling collection. 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 backend facts, not source-tree identity.
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.
## Verification

View File

@ -7,8 +7,10 @@
1. 调用者拥有新的 source tree。
2. `ebox-tree.el` 拥有 identity、key、遍历和 snapshot。
3. `ebox-style.el`、`ebox-measure.el` 与布局模块拥有归一化、测量和几何。
4. `ebox-incremental.el` 拥有 dirty 分类、patch 规划、候选发布和更新报告。
5. `ebox-buffer-backend.el` 是 text property 与 buffer 变更的唯一 owner。
4. `ebox-incremental.el` 拥有 dirty 分类、patch 规划、纯声明式 commit 准备和 Ebox 报告语义。
5. `ebox-surface.el` 投影 retained candidate并让 Ebox runtime-state 发布加入 TP transaction。
6. TP 拥有首次 mount 与声明式整根 commit 的通用 retained-surface reconciliation、buffer diff 执行、revision 变更和 rollback。
7. `ebox-buffer-backend.el` 拥有 Ebox marker/index primitive以及尚未完成局部更新切换的数字 region、viewport 与 scroll 专用 publisher。
任何层都不能从可见 buffer 文本推断应用状态;公共调用者不能原地修改已经发布的树。
@ -17,23 +19,25 @@
```text
新的根树
-> 校验与归一化
-> reconciliation key 与 identity
-> 复制到 surface-owned candidate state
-> reconciliation TP object、key 与 Ebox identity
-> 测量/布局候选
-> 分类 dirty owner
-> 渲染候选 span
-> 原子发布
-> 刷新 snapshot 与报告
-> 生成 TP surface plan 与 Ebox report base
-> TP 准备并原子发布 buffer 变更
-> Ebox transaction participant 发布匹配的 runtime index/report
-> 成功后退役旧 marker 与 timer
```
候选要么完整发布,要么丢弃。候选失败时必须保留之前的 buffer 文本、属性、runtime identity、滚动状态和最近一次成功报告。
候选要么完整发布,要么丢弃。render、TP 写入、Ebox participant 或发布回调失败时,必须保留之前的 buffer 文本、属性、TP revision、Ebox runtime identity、滚动状态和最近一次成功报告。如果目标 buffer 在发布期间被 killteardown 是最终状态rollback 绝不能把它复活。
## Patch 顺序
规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。Patch 不得静默扩大范围报告要记录策略、dirty key、owner 和 patch 操作
规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。Patch 不得静默扩大语义范围。声明式整根报告保留该 Ebox strategy 与 planned scope同时单独记录 TP 的实际 surface operation 和 revision
## Identity 与坐标
Key 只在同级兄弟集合内有效。Region 与 host-ref 位置绑定于 generationbuffer 变更后调用者必须通过公共 accessor 重新获得位置。Buffer marker、display span 和 text property 是 backend 事实,不是 source tree 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

@ -68,17 +68,17 @@ Grid tracks can be fixed, fractional, `auto`, `minmax`, or repeated. Explicit pl
## 5. Render text or a buffer
`ebox-render` is pure with respect to buffers and returns propertized text. `ebox-render-to-buffer` owns the initial buffer publication:
`ebox-render` is pure with respect to buffers and returns propertized text through an ephemeral TP surface. `ebox-render-to-buffer` copies the declarative source and mounts a retained TP surface for initial publication:
```elisp
(let ((node (ebox-column
(ebox-create :content "Ready" :width '(240))
(ebox-create :id "status" :content "Ready" :width '(240))
(ebox-create :content "Rendered by Ebox"))))
(ebox-render node)
(ebox-render-to-buffer "*Ebox Demo*" node))
```
The returned text carries display, face, region, and identity properties needed by Ebox. Do not edit those properties by hand.
The returned text carries display, face, region, and identity properties needed by Ebox. Do not edit those properties by hand. The source node remains caller-owned and can be mounted in more than one buffer; each buffer receives independent runtime identity and state.
## 6. Update an existing buffer
@ -91,17 +91,24 @@ Build a fresh root tree and commit it to the existing buffer:
(ebox-create :content "Updated" :key 'title :width '(240))))
```
Ebox compares stable keys and region identity, chooses the smallest safe patch, and records a report:
Ebox compares stable keys and region identity, prepares the semantic dirty/owner plan, and asks TP to atomically publish the new retained surface and Ebox runtime state. It records both the Ebox plan and TP execution summary:
```elisp
(ebox-buffer-update-report "*Ebox Demo*")
```
If a candidate cannot be proven safe, Ebox escalates to an owner or root rerender. A failed commit must leave the previously published tree and buffer intact.
If a candidate cannot be proven safe, Ebox escalates to an owner or root rerender. A failed render, publication, runtime-state swap, or publication callback leaves the previous buffer, TP surface, Ebox runtime state, and last successful report intact.
## 7. Selectors and handles
Selectors query the rendered tree and return public handles. They do not edit the buffer or own application state. Prefer explicit keys and refs for application identity; use selectors for inspection and bounded updates.
Selectors query the rendered tree and return public match records. They do not own application state. Give an editable box a logical `:id`, resolve it in one live buffer, and pass the opaque surface-scoped handle to `ebox-region-update`:
```elisp
(let ((handle (ebox-region-resolve "*Ebox Demo*" "status")))
(ebox-region-update handle :content "Ready" :color "#166534"))
```
The same logical id in two buffers resolves to two different handles, so updating one surface cannot accidentally mutate the other. A handle becomes stale when its retained object is removed or its buffer is killed. Prefer `ebox-region-resolve` handles for new multi-buffer code; numeric region ids remain an internal/legacy update form during the local-update cutover.
## 8. Standalone `.ebox` files

View File

@ -68,17 +68,17 @@ Grid 轨道支持固定值、分数、`auto`、`minmax` 和重复轨道。显式
## 5. 渲染文本或 buffer
`ebox-render` 相对于 buffer 是纯函数,返回带属性文本;`ebox-render-to-buffer` 负责首次发布:
`ebox-render` 相对于 buffer 是纯函数,通过临时 TP surface 返回带属性文本;`ebox-render-to-buffer` 会复制声明式 source并挂载 retained TP surface 完成首次发布:
```elisp
(let ((node (ebox-column
(ebox-create :content "Ready" :width '(240))
(ebox-create :id "status" :content "Ready" :width '(240))
(ebox-create :content "Rendered by Ebox"))))
(ebox-render node)
(ebox-render-to-buffer "*Ebox Demo*" node))
```
返回文本带有 Ebox 所需的 display、face、region 和 identity 属性,不要手动修改这些属性。
返回文本带有 Ebox 所需的 display、face、region 和 identity 属性,不要手动修改这些属性。source 节点仍由调用者拥有,可以挂载到多个 buffer每个 buffer 都获得独立的 runtime identity 与状态。
## 6. 更新已有 buffer
@ -91,17 +91,24 @@ Grid 轨道支持固定值、分数、`auto`、`minmax` 和重复轨道。显式
(ebox-create :content "Updated" :key 'title :width '(240))))
```
Ebox 会比较稳定 key 和 region identity选择最小且安全的 patch并记录报告
Ebox 会比较稳定 key 和 region identity准备 dirty/owner 语义计划,再由 TP 原子发布新的 retained surface 与 Ebox runtime 状态。报告同时保留 Ebox 计划与 TP 执行摘要
```elisp
(ebox-buffer-update-report "*Ebox Demo*")
```
无法证明安全时会提升到 owner 或 root rerender候选提交失败必须保留之前已发布的树和 buffer
无法证明安全时会提升到 owner 或 root rerenderrender、发布、runtime 状态交换或发布回调失败时,之前的 buffer、TP surface、Ebox runtime 状态和最近一次成功报告都必须保持不变
## 7. Selector 与 handle
Selector 查询已渲染的树并返回公共 handle它不编辑 buffer也不拥有应用状态。应用 identity 优先使用显式 key 和 refselector 适合检查和有限更新。
Selector 查询已渲染的树并返回公共匹配记录,但不拥有应用状态。给可编辑 box 设置逻辑 `:id`,在某个 live buffer 中解析它再把不透明、surface-scoped 的 handle 交给 `ebox-region-update`
```elisp
(let ((handle (ebox-region-resolve "*Ebox Demo*" "status")))
(ebox-region-update handle :content "Ready" :color "#166534"))
```
两个 buffer 中相同的逻辑 id 会解析为两个不同 handle因此更新一个 surface 不会误改另一个。retained object 被删除或 buffer 被 kill 后,原 handle 会变 stale。新的多 buffer 代码应优先使用 `ebox-region-resolve` handle在局部更新执行器切换完成前数字 region id 仍作为内部/旧式更新形式保留。
## 8. 独立 `.ebox` 文件

View File

@ -115,7 +115,7 @@
(declare-function ebox--smooth-scroll-stop
"ebox" (region-id))
(declare-function ebox--install-buffer-scroll-content-span-template
"ebox" (buffer template start))
"ebox" (buffer template start &optional defer-window-region-set))
(declare-function ebox--node-children
"ebox" (node))
(declare-function ebox--root-region-box
@ -250,6 +250,10 @@ user code or signal after mutation.")
'ebox-incremental-before-runtime-mutation-hook buffer kind)))
(defvar ebox--buffer-render-state-table)
(defvar ebox-surface--buffer-surface)
(declare-function tp-surface-client-state "tp-surface" (surface))
(declare-function tp-surface-live-p "tp-surface" (surface))
(defvar ebox-render-cache-max-entries 2048
"Maximum persistent render cache entries retained per buffer.
@ -392,7 +396,7 @@ Kept as a narrow predicate for callers that need box-only sizing semantics."
(while plist
(let ((key (pop plist))
(value (pop plist)))
(unless (eq key :render-cache)
(unless (memq key '(:render-cache :surface-object))
(push key signature)
(push (ebox--render-cache-value-signature value) signature))))
(nreverse signature)))
@ -968,7 +972,14 @@ Keys are region-id, values are plists with :scroll-offset, :content-lines, etc."
(eq buffer
(car ebox-incremental--buffer-render-state-override)))
(cdr ebox-incremental--buffer-render-state-override)
(gethash buffer ebox--buffer-render-state-table)))
(let ((surface
(and (buffer-live-p buffer)
(boundp 'ebox-surface--buffer-surface)
(with-current-buffer buffer
ebox-surface--buffer-surface))))
(if (and surface (tp-surface-live-p surface))
(tp-surface-client-state surface)
(gethash buffer ebox--buffer-render-state-table)))))
(defun ebox--buffer-update-report (buffer)
"Return the update report stored in BUFFER's render state."
@ -4414,7 +4425,8 @@ prepared proof and publication cannot render, promote owners, or fall back."
(plist-get root-metadata
:scroll-content-span-template)
(caar (plist-get (car results)
:new-buffer-spans)))))
:new-buffer-spans))
t)))
(incremental-scroll-refresh
(and (eq refresh-scope :incremental-root)
(plist-get (car results)
@ -6331,6 +6343,44 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
root node-id replacement index))))
root)))))
(defun ebox-incremental-consume-candidate (buffer-or-name candidate)
"Consume CANDIDATE for BUFFER-OR-NAME and return its pure surface input.
The returned plist contains the prepared root, Ebox plan report, identity
policy, and runtime state overrides needed by the TP surface publisher.
CANDIDATE is sealed before target and staleness validation, so every attempted
commit consumes it even when later preparation fails."
(unless (ebox-candidate-p candidate)
(error "Ebox logical commit requires an Ebox candidate"))
(when (ebox-candidate--sealed-p candidate)
(error "Ebox candidate is already sealed"))
(setf (ebox-candidate--sealed-p candidate) t)
(let ((buffer (get-buffer buffer-or-name))
(old-state (ebox-candidate--base-state candidate)))
(unless (buffer-live-p buffer)
(error "Ebox candidate commit requires a live buffer: %S"
buffer-or-name))
(ebox-incremental--candidate-assert-current candidate buffer old-state)
(ebox-incremental--surface-commit-input
buffer old-state
(ebox-incremental--prepare-logical-candidate
buffer old-state candidate))))
(defun ebox-incremental-prepare-root-commit (buffer-or-name next-root)
"Prepare declarative NEXT-ROOT for TP publication in BUFFER-OR-NAME.
Return the same pure surface-input shape as
`ebox-incremental-consume-candidate'."
(let* ((buffer (get-buffer buffer-or-name))
(old-state (and buffer (ebox--buffer-render-state buffer))))
(unless (buffer-live-p buffer)
(error "Ebox declarative commit requires a live buffer: %S"
buffer-or-name))
(unless old-state
(error "Ebox buffer has no rendered runtime: %S" buffer))
(ebox-incremental--surface-commit-input
buffer old-state
(ebox-incremental--prepare-declarative-root
buffer old-state next-root))))
(defun ebox-incremental--hash-keys (table)
"Return the keys currently present in hash TABLE."
(let (keys)
@ -8334,6 +8384,123 @@ All owner promotion and rendering finishes before this function returns."
promoted)))))))
complete)))
(defun ebox-incremental--prepared-plan-result (op)
"Return publication metadata predicted by prepared patch OP."
(let* ((prepared (plist-get op :prepared-publication))
(spans (or (plist-get prepared :spans)
(plist-get prepared :old-refresh-spans)))
(result
(list :op (plist-get op :op)
:owner-id (plist-get op :owner-id)
:owner-type (plist-get prepared :owner-type)
:span-count (length spans))))
(dolist (key '(:affected-region-ids :slot-preserving
:flex-line-rerender :publication-scope
:published-buffer-spans :full-root-required-p
:patch-origin :full-frame-bytes :replacement-bytes
:old-span-footprint-signature
:new-span-footprint-signature
:old-external-footprint-signature
:new-external-footprint-signature
:old-parent-slot-signature
:new-parent-slot-signature))
(when (plist-member prepared key)
(setq result (plist-put result key (plist-get prepared key)))))
result))
(defun ebox-incremental--prepared-plan-report
(prepared patch-set candidate-state)
"Return Ebox's semantic report for PREPARED PATCH-SET and CANDIDATE-STATE."
(let* ((results (mapcar #'ebox-incremental--prepared-plan-result patch-set))
(strategy (and results (ebox--patch-results-strategy results)))
(single (and (= (length results) 1) (car results)))
(patch-report
(and results
(ebox--update-report
nil strategy
:dirty-count (length (plist-get prepared :dirty-set))
:patch-count (length results)
:patch-ops (mapcar (lambda (result)
(plist-get result :op))
results)
:owner-ids (mapcar (lambda (result)
(plist-get result :owner-id))
results)
:owner-type (plist-get single :owner-type)
:span-count (cl-loop for result in results
sum (plist-get result :span-count))
:slot-preserving
(cl-some (lambda (result)
(plist-get result :slot-preserving))
results)
:old-span-footprint-signature
(plist-get single :old-span-footprint-signature)
:new-span-footprint-signature
(plist-get single :new-span-footprint-signature)
:old-external-footprint-signature
(plist-get single :old-external-footprint-signature)
:new-external-footprint-signature
(plist-get single :new-external-footprint-signature)
:old-parent-slot-signature
(plist-get single :old-parent-slot-signature)
:new-parent-slot-signature
(plist-get single :new-parent-slot-signature)))))
(ebox-incremental--commit-report
prepared patch-report candidate-state)))
(defun ebox-incremental--prepare-surface-patch-report
(buffer old-state prepared candidate-state)
"Prove BUFFER's PREPARED candidate against OLD-STATE.
Return a non-publishing report for CANDIDATE-STATE."
(let* ((render-dirty-set
(cl-remove-if
(lambda (entry) (eq (plist-get entry :dirty-kind) 'metadata))
(plist-get prepared :dirty-set)))
(candidate-region-box-table
(plist-get candidate-state :region-box-table))
(candidate-scroll-state-table
(plist-get prepared :scroll-state-table))
(base-buffer-modified-tick
(with-current-buffer buffer (buffer-modified-tick)))
patch-set)
(let ((ebox--region-box-table candidate-region-box-table)
(ebox--scroll-global-state candidate-scroll-state-table)
(ebox--scroll-idle-prefetch-timers (make-hash-table :test 'equal))
(ebox--smooth-scroll-state-table (make-hash-table :test 'equal))
(ebox--render-cache-scroll-state-restorable-p nil)
(ebox-incremental--buffer-render-state-override
(cons buffer candidate-state))
(ebox-incremental--candidate-base-state (cons buffer old-state)))
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (&rest _) nil)))
(setq patch-set
(ebox-incremental--prepare-declarative-patch-set
buffer render-dirty-set))))
(unless (= base-buffer-modified-tick
(with-current-buffer buffer (buffer-modified-tick)))
(error "Ebox buffer changed while declarative patches were prepared"))
(ebox-incremental--prepared-plan-report
prepared patch-set candidate-state)))
(defun ebox-incremental--surface-commit-input
(buffer old-state prepared)
"Return TP surface input for BUFFER from OLD-STATE and PREPARED runtime."
(let* ((candidate-state
(ebox-incremental--candidate-state
old-state (plist-get prepared :root)
(plist-get prepared :index) prepared))
(report
(ebox-incremental--prepare-surface-patch-report
buffer old-state prepared candidate-state)))
(list :root (plist-get prepared :root)
:report-base report
:preserve-identities-p t
:state-overrides
(list :logical-candidate-p
(plist-get prepared :logical-candidate-p)
:detached-identity-history
(plist-get prepared :detached-identity-history)))))
(defun ebox-incremental--finalize-declarative-scroll-publication
(scroll-keys)
"Retire stale scroll timers and resume prefetch for committed SCROLL-KEYS."

View File

@ -19,6 +19,9 @@
(defvar ebox--render-runtime-revision nil
"Current buffer runtime revision for render-local cache validation.")
(defvar ebox--region-id-counter)
(defvar ebox--runtime-node-id-counter)
(defvar ebox--intrinsic-layout-measurement nil
"Non-nil while measuring intrinsic layout contributions.
Descendants still see the current containing block, but stack/column containers
@ -1394,15 +1397,17 @@ Internal implementation of `ebox-render' for box nodes."
(setq result (ebox--apply-hidden-visibility result)))
result)))
;;;###autoload
(defun ebox-render (node)
"Render a layout NODE to a multi-line string.
(defvar ebox--surface-materialization-active nil
"Non-nil while an Ebox TP producer is running raw layout.")
(defun ebox--render-layout (node)
"Render layout NODE directly to a multi-line propertized string.
NODE can be:
- a box plist created by `ebox-create'
- a concat node created by `ebox-concat'
- a stack node created by `ebox-stack'
- a flex node created by `ebox-flex'
This is the single public rendering entry point."
This function owns spatial layout only; it never creates a live TP surface."
(ebox--with-validated-display-cache
(let ((ebox--box-content-render-cache
(or ebox--box-content-render-cache
@ -1416,9 +1421,30 @@ This is the single public rendering entry point."
((eq type 'stack) (ebox--render-stack node))
((eq type 'flex) (ebox--render-flex node))
((eq type 'grid) (ebox--render-grid node))
((eq type 'flex-item) (ebox-render (plist-get node :node)))
((eq type 'flex-item) (ebox--render-layout (plist-get node :node)))
(t (error "ebox-render: unknown node type %S" type)))))))
(declare-function ebox-surface-producer
"ebox-surface"
(source &optional previous-state preserve-identities-p
state-overrides))
(declare-function tp-surface-materialize-string "tp-surface" (plan-or-producer))
;;;###autoload
(defun ebox-render (node)
"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)
(let ((ebox--surface-materialization-active t)
(ebox--region-id-counter ebox--region-id-counter)
(ebox--runtime-node-id-counter ebox--runtime-node-id-counter))
(tp-surface-materialize-string
(ebox-surface-producer node nil t)))))
(defun ebox--width-pad (string extra-pixels)
"Append a display-space of EXTRA-PIXELS to the right of every line in STRING.
Used by `ebox--render-stack' to equalise rows to the same total width."
@ -1993,6 +2019,7 @@ last cached leaf instead of walking the stack from the beginning."
max-pixel-width-cache)
(ebox--render-recached-source-node-cache
recached-source-node-cache)
(ebox--surface-materialization-active t)
(ebox--scroll-window-prewarm-state prewarm-state)
(ebox--scroll-window-render-disabled t)
(ebox--scroll-window-render-result nil))

View File

@ -10,6 +10,7 @@
(defvar ebox-viewport-width nil)
(defvar ebox-viewport-height nil)
(defvar ebox--render-runtime-revision nil)
(defvar ebox--surface-materialization-active nil)
(defvar ebox--render-cache-table nil)
(defvar ebox--render-cache-signature-cache nil)
(defvar ebox--viewport-dependent-node-ids-cache nil)
@ -36,6 +37,7 @@
(plist-get ,render-state :viewport-height))
(ebox--render-runtime-revision
(plist-get ,render-state :runtime-revision))
(ebox--surface-materialization-active t)
(ebox--render-cache-table
(plist-get ,render-state :render-cache))
(ebox--render-cache-signature-cache

View File

@ -10,10 +10,12 @@
(require 'subr-x)
(require 'ebox-buffer-backend)
(require 'ebox-tree)
(require 'tp-surface)
(declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox--buffer-root-node "ebox-incremental" (buffer))
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-id-table "ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-class-table
"ebox-incremental" (buffer))
@ -24,6 +26,71 @@
(declare-function ebox-region-update "ebox" (region-id &rest props))
(defvar ebox--region-update-buffer-hint)
(cl-defstruct
(ebox-region-handle
(:constructor ebox-selector--make-region-handle)
(:conc-name ebox-selector--region-handle-))
"Opaque surface-scoped identity for one editable Ebox region."
buffer surface object logical-id)
(defun ebox-selector--live-region-handle
(buffer object &optional logical-id)
"Return a live region handle for OBJECT in BUFFER, or nil."
(let* ((state (ebox--buffer-render-state buffer))
(surface (plist-get state :surface))
(region-id
(and (tp-object-live-p object)
(gethash object
(plist-get state :surface-object-region-table)))))
(when (and (tp-surface-live-p surface) region-id)
(ebox-selector--make-region-handle
:buffer buffer :surface surface :object object :logical-id logical-id))))
;;;###autoload
(defun ebox-region-resolve (buffer-or-name logical-id)
"Resolve LOGICAL-ID in BUFFER-OR-NAME to an opaque editable region handle."
(let ((buffer (get-buffer buffer-or-name)))
(unless (buffer-live-p buffer)
(user-error "Ebox region target is not a live buffer: %S"
buffer-or-name))
(let* ((state (ebox--buffer-render-state buffer))
(normalized (ebox-selector--metadata-string logical-id))
(entries
(and state
(gethash normalized
(plist-get state :logical-id-region-table))))
(handles
(delq nil
(mapcar
(lambda (entry)
(ebox-selector--live-region-handle
buffer (car entry) normalized))
entries))))
(pcase (length handles)
(0 (user-error "Ebox logical region does not exist: %S" logical-id))
(1 (car handles))
(_ (user-error "Ebox logical region is ambiguous: %S" logical-id))))))
(defun ebox-selector--region-target (handle)
"Return HANDLE's current internal `(BUFFER . REGION-ID)' target."
(unless (ebox-region-handle-p handle)
(signal 'wrong-type-argument (list 'ebox-region-handle-p handle)))
(let* ((buffer (ebox-selector--region-handle-buffer handle))
(surface (ebox-selector--region-handle-surface handle))
(object (ebox-selector--region-handle-object handle))
(state (and (buffer-live-p buffer)
(ebox--buffer-render-state buffer)))
(region-id
(and state
(eq surface (plist-get state :surface))
(tp-surface-live-p surface)
(tp-object-live-p object)
(gethash object
(plist-get state :surface-object-region-table)))))
(unless region-id
(user-error "Ebox region handle is stale"))
(cons buffer region-id)))
(defun ebox-selector--identifier-char-p (char)
"Return non-nil when CHAR is accepted in a selector identifier."
(or (and (>= char ?a) (<= char ?z))
@ -331,7 +398,19 @@ selectors match the user's logical layout children."
(defun ebox-selector--handle-with-buffer (handle buffer)
"Return selector HANDLE annotated with BUFFER ownership."
(append handle (list :buffer buffer)))
(let* ((state (ebox--buffer-render-state buffer))
(region-id (plist-get handle :region-id))
(object
(and region-id
(gethash region-id
(plist-get state :region-surface-object-table))))
(region-handle
(and object
(ebox-selector--live-region-handle
buffer object
(ebox-tree-node-id (plist-get handle :node))))))
(append handle
(list :buffer buffer :region-handle region-handle))))
(defun ebox-selector--single-id-simple (sequence)
"Return the simple selector when SEQUENCE is a single #id candidate."
@ -470,9 +549,8 @@ tree-query handle fields."
(defun ebox-selector--update-region (handle props)
"Apply PROPS to HANDLE's region through `ebox-region-update'."
(let* ((region-id (plist-get handle :region-id))
(ebox--region-update-buffer-hint (plist-get handle :buffer)))
(apply #'ebox-region-update region-id props)))
(let ((ebox--region-update-buffer-hint (plist-get handle :buffer)))
(apply #'ebox-region-update (plist-get handle :region-id) props)))
;;;###autoload
(defun ebox-selector-update-buffer (buffer selector &rest props)
@ -489,7 +567,7 @@ skips, and update reports."
(unless matches
(user-error "ebox-selector: no matches for %S" selector))
(dolist (match matches)
(if (plist-get match :region-id)
(if (plist-get match :region-handle)
(push match editable)
(push (ebox-selector--skip-handle match 'no-region) skipped)))
(setq editable (nreverse editable)

View File

@ -1,11 +1,12 @@
;;; ebox-surface.el --- Pure retained surface projection -*- lexical-binding: t; -*-
;;; ebox-surface.el --- Retained projection and publication -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Owns the pure boundary from an Ebox candidate runtime and rendered layout to
;; a generic TP surface plan. It never publishes or scans a live buffer.
;; Owns the boundary from an Ebox candidate runtime to a generic TP surface
;; plan, and the atomic publication participant that mirrors the committed TP
;; generation into Ebox's buffer-local runtime indexes.
;;; Code:
@ -20,10 +21,272 @@
(defvar ebox--scroll-global-state)
(defvar ebox--scroll-idle-prefetch-timers)
(defvar ebox--smooth-scroll-state-table)
(defvar ebox--box-extents)
(defvar ebox--box-extent-template)
(defvar ebox--buffer-render-state-table)
(defvar ebox--viewport-dependent-node-ids-cache)
(defvar ebox--viewport-dependent-subtree-cache)
(defvar ebox--viewport-height-dependent-subtree-cache)
(defvar ebox--render-runtime-revision)
(defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache)
(defvar ebox--flex-content-min-width-table)
(declare-function ebox-render "ebox-layout" (node))
(declare-function ebox--render-layout "ebox-layout" (node))
(declare-function ebox-buffer--build-region-role-span-template
"ebox-buffer-backend" (string))
(declare-function ebox-buffer-materialize-region-role-spans
"ebox-buffer-backend" (buffer))
(declare-function ebox--build-box-extent-template "ebox" (rendered))
(declare-function ebox--build-scroll-content-span-template "ebox" (string))
(declare-function ebox--set-box-extents "ebox" (region-id start end))
(declare-function ebox--clear-buffer-extents
"ebox" (&optional buffer preserve-template))
(declare-function ebox--clear-box-extents-for-region-ids
"ebox" (region-ids))
(declare-function ebox--install-box-extent-template
"ebox" (buffer template start))
(declare-function ebox--install-buffer-scroll-content-span-template
"ebox" (buffer template start &optional defer-window-region-set))
(declare-function ebox-incremental--hash-snapshot
"ebox-incremental" (table keys))
(declare-function ebox-incremental--restore-hash-snapshot
"ebox-incremental" (table entries))
(declare-function ebox-incremental--replace-hash-entries
"ebox-incremental" (target keys source))
(declare-function ebox-incremental--detach-scroll-state-table-markers
"ebox-incremental" (table))
(declare-function ebox--runtime-region-id-conflict
"ebox-incremental" (region-id-set target-buffer))
(declare-function ebox--viewport-dependent-node-id-axes
"ebox-incremental" (node))
(declare-function ebox-incremental--detach-region-role-span-table
"ebox-incremental" (table))
(declare-function ebox-incremental--finalize-declarative-scroll-publication
"ebox-incremental" (scroll-keys))
(declare-function ebox--scroll-schedule-idle-prefetch
"ebox" (region-id &optional delay))
(declare-function ebox--scroll-clear-state "ebox" (region-id))
(declare-function ebox--smooth-scroll-stop "ebox" (region-id))
(defvar-local ebox-surface--buffer-surface nil
"Live TP content surface mounted for the current Ebox buffer.")
(defun ebox-surface--live-buffer-surface (buffer)
"Return BUFFER's live Ebox TP surface, or nil."
(when (buffer-live-p buffer)
(let ((surface
(with-current-buffer buffer ebox-surface--buffer-surface)))
(and (tp-surface-live-p surface) surface))))
(defun ebox-surface--buffer-extent-snapshot (buffer)
"Return numeric live box extents owned by BUFFER."
(let (snapshot)
(maphash
(lambda (region-id extents)
(when (eq (marker-buffer (car extents)) buffer)
(push (list region-id
(marker-position (car extents))
(marker-position (cdr extents)))
snapshot)))
ebox--box-extents)
snapshot))
(defun ebox-surface--restore-buffer-extents (buffer snapshot template)
"Restore BUFFER box extents from SNAPSHOT and numeric TEMPLATE."
(ebox--clear-buffer-extents buffer)
(when (buffer-live-p buffer)
(with-current-buffer buffer
(setq-local ebox--box-extent-template template)
(dolist (entry snapshot)
(ebox--set-box-extents (nth 0 entry) (nth 1 entry) (nth 2 entry))))))
(defun ebox-surface--runtime-keys (state key)
"Return hash keys stored under KEY in runtime STATE."
(when-let ((table (plist-get state key)))
(ebox-surface--hash-keys table)))
(defun ebox-surface--commit-report (surface state report-base)
"Return Ebox's compact report for SURFACE, STATE, and REPORT-BASE."
(let* ((tp-report (tp-surface-report surface))
(text-operations (or (plist-get tp-report :text-operations) 0))
(property-operations
(or (plist-get tp-report :property-operations) 0))
(patch-count (+ text-operations property-operations))
(report (copy-sequence report-base))
(planned-publication-scope (plist-get report :publication-scope)))
(dolist (entry
`((:runtime-published . t)
(:runtime-revision . ,(plist-get state :runtime-revision))
(:surface-revision . ,(tp-surface-revision surface))
(:publication-scope . tp-surface)
(:planned-publication-scope . ,planned-publication-scope)
(:tp-render-scope . surface-plan)
(:tp-operation-count . ,patch-count)
(:tp-transaction-id . ,(plist-get tp-report :transaction-id))
(:tp-text-operations . ,text-operations)
(:tp-property-operations . ,property-operations)
(:reconciled-objects
. ,(plist-get tp-report :reconciled-objects))
(:created-objects . ,(plist-get tp-report :created-objects))
(:removed-objects . ,(plist-get tp-report :removed-objects))
(:moved-objects . ,(plist-get tp-report :moved-objects))))
(setq report (plist-put report (car entry) (cdr entry))))
(unless (plist-member report :strategy)
(setq report
(plist-put report :strategy
(if (zerop patch-count) 'no-op 'surface-commit))))
(unless (plist-member report :constraint-source)
(setq report (plist-put report :constraint-source 'declarative)))
(unless (plist-member report :constraint-root-node-id)
(setq report
(plist-put report :constraint-root-node-id
(plist-get (plist-get state :root-node) :node-id))))
(unless (plist-member report :dirty-count)
(setq report
(plist-put report :dirty-count
(if (zerop patch-count) 0 1))))
(unless (plist-member report :patch-count)
(setq report (plist-put report :patch-count patch-count)))
(unless (plist-member report :patch-ops)
(setq report
(plist-put report :patch-ops
(and (> patch-count 0) '(tp-surface)))))
report))
(defun ebox-surface--publish-runtime-state
(buffer surface old-state report-base after-publication)
"Register BUFFER runtime publication for SURFACE after OLD-STATE.
REPORT-BASE requests an Ebox commit report. AFTER-PUBLICATION, when non-nil,
runs after TP and Ebox point at the same candidate generation."
(let (new-state region-snapshot scroll-snapshot extent-snapshot
old-template old-surface old-mirror region-keys scroll-keys)
(tp-transaction-participate
(list 'ebox/runtime buffer)
(lambda ()
(setq new-state (tp-surface-client-state surface)
old-surface
(with-current-buffer buffer ebox-surface--buffer-surface)
old-mirror (gethash buffer ebox--buffer-render-state-table)
old-template (with-current-buffer buffer ebox--box-extent-template)
extent-snapshot (ebox-surface--buffer-extent-snapshot buffer)
region-keys
(delete-dups
(append (ebox-surface--runtime-keys old-state :region-id-set)
(ebox-surface--runtime-keys new-state :region-id-set)))
scroll-keys
(delete-dups
(append (copy-sequence (plist-get old-state :scroll-region-ids))
(copy-sequence (plist-get new-state :scroll-region-ids)))))
(setq region-snapshot
(ebox-incremental--hash-snapshot
ebox--region-box-table region-keys)
scroll-snapshot
(ebox-incremental--hash-snapshot
ebox--scroll-global-state scroll-keys))
(when new-state
(plist-put new-state :surface surface)
(plist-put new-state :runtime-revision
(1- (tp-surface-revision surface)))
(when report-base
(plist-put new-state :last-update-report
(ebox-surface--commit-report
surface new-state report-base))))
(with-current-buffer buffer
(setq-local ebox-surface--buffer-surface surface))
(if new-state
(puthash buffer new-state ebox--buffer-render-state-table)
(remhash buffer ebox--buffer-render-state-table))
(ebox-incremental--replace-hash-entries
ebox--region-box-table region-keys
(plist-get new-state :region-box-table))
(ebox-incremental--replace-hash-entries
ebox--scroll-global-state scroll-keys
(plist-get new-state :scroll-state-table))
(ebox--clear-buffer-extents buffer)
(when new-state
(ebox--install-box-extent-template
buffer (plist-get new-state :box-extent-template) 1)
(ebox--install-buffer-scroll-content-span-template
buffer (plist-get new-state :scroll-content-span-template) 1)
(ebox-buffer-materialize-region-role-spans buffer))
(when after-publication
(funcall after-publication
(plist-get new-state :last-update-report)))
(unless (buffer-live-p buffer)
(error "Ebox declarative target died during publication")))
(lambda ()
(when-let ((table (plist-get new-state :scroll-state-table)))
(ebox-incremental--detach-scroll-state-table-markers table))
(if (buffer-live-p buffer)
(progn
(when region-snapshot
(ebox-incremental--restore-hash-snapshot
ebox--region-box-table region-snapshot))
(when scroll-snapshot
(ebox-incremental--restore-hash-snapshot
ebox--scroll-global-state scroll-snapshot))
(with-current-buffer buffer
(setq-local ebox-surface--buffer-surface old-surface))
(ebox-surface--restore-buffer-extents
buffer extent-snapshot old-template)
(if old-mirror
(puthash buffer old-mirror ebox--buffer-render-state-table)
(remhash buffer ebox--buffer-render-state-table)))
(remhash buffer ebox--buffer-render-state-table)
(dolist (region-id region-keys)
(remhash region-id ebox--region-box-table))
(dolist (region-id scroll-keys)
(ebox--scroll-clear-state region-id)
(ebox--smooth-scroll-stop region-id))
(ebox--clear-box-extents-for-region-ids region-keys))))))
(defun ebox-surface--retire-state (old-state new-state)
"Retire marker and timer resources in OLD-STATE after NEW-STATE commits."
(when old-state
(let ((old-role-table (plist-get old-state :region-role-span-table))
(new-role-table (plist-get new-state :region-role-span-table)))
(unless (eq old-role-table new-role-table)
(ebox-incremental--detach-region-role-span-table old-role-table)))
(let ((old-scroll-table (plist-get old-state :scroll-state-table))
(new-scroll-table (plist-get new-state :scroll-state-table)))
(unless (eq old-scroll-table new-scroll-table)
(ebox-incremental--detach-scroll-state-table-markers old-scroll-table)))))
(defun ebox-surface-mount-buffer
(buffer source &optional report-base after-publication
preserve-identities-p state-overrides)
"Mount or atomically update BUFFER from Ebox SOURCE and return its surface.
REPORT-BASE requests a committed Ebox report. AFTER-PUBLICATION runs inside
the rollback-capable transaction. PRESERVE-IDENTITIES-P retains identities
already present in a logical candidate. STATE-OVERRIDES augments its runtime."
(let* ((surface (ebox-surface--live-buffer-surface buffer))
(old-state (and surface (tp-surface-client-state surface)))
(preserve-identities-p
(or preserve-identities-p
(and (null old-state)
(ebox-surface--source-identities-available-p
source buffer))))
(producer
(ebox-surface-producer
source old-state preserve-identities-p state-overrides)))
(tp-with-transaction
(if surface
(tp-surface-update surface producer)
(setq surface
(tp-surface-mount
buffer producer
'(:capability content :inhibit-read-only t))))
(ebox-surface--publish-runtime-state
buffer surface old-state report-base after-publication))
(let* ((new-state (tp-surface-client-state surface))
(scroll-keys
(delete-dups
(append (copy-sequence (plist-get old-state :scroll-region-ids))
(copy-sequence (plist-get new-state :scroll-region-ids))))))
(ebox-surface--retire-state old-state new-state)
(ebox-incremental--finalize-declarative-scroll-publication scroll-keys))
surface))
(defconst ebox-surface--root-key 'ebox/surface
"Stable TP key for an Ebox surface projection root.")
@ -34,18 +297,55 @@
(defconst ebox-surface--fragments-key 'ebox/fragments
"Stable TP key for linear rendered fragments.")
(defun ebox-surface--candidate-root (source previous-state)
"Return an isolated runtime copy of SOURCE reconciled with PREVIOUS-STATE."
(defun ebox-surface--clear-runtime-attachments (root)
"Clear TP handles and render-cache attachments below ROOT."
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
(when (plist-member node :surface-object)
(plist-put node :surface-object nil))
(when (plist-member node :render-cache)
(plist-put node :render-cache nil))
(dolist (child (ebox-tree--children-raw node))
(visit child)))))
(visit root))
root)
(defun ebox-surface--candidate-root
(source previous-state preserve-identities-p)
"Return an isolated runtime copy of SOURCE for PREVIOUS-STATE.
When PRESERVE-IDENTITIES-P is non-nil, retain existing Ebox node and region
identities while always discarding TP handles and render-cache attachments."
(unless (and (listp source) (not (stringp source)))
(error "Ebox surface source must be an Ebox node"))
(ebox-tree-validate-declarative-root source)
(let ((candidate
(ebox-tree-clear-runtime-identities
(ebox-tree-copy-node-structure source))))
(let ((candidate (ebox-tree-copy-node-structure source)))
(if preserve-identities-p
(ebox-surface--clear-runtime-attachments candidate)
(ebox-tree-clear-runtime-identities candidate))
(when-let ((previous-root (plist-get previous-state :root-node)))
(ebox-tree-reconcile-runtime previous-root candidate))
candidate))
(defun ebox-surface--source-region-id-set (source)
"Return region ids already present in Ebox SOURCE without mutating it."
(let ((ids (make-hash-table :test 'equal)))
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
(when-let ((region-id (plist-get node :region-id)))
(puthash region-id t ids))
(dolist (child (ebox-tree--children-raw node))
(visit child)))))
(visit source))
ids))
(defun ebox-surface--source-identities-available-p (source buffer)
"Return non-nil when SOURCE's migration ids are available for BUFFER."
(or (stringp source)
(not (ebox--runtime-region-id-conflict
(ebox-surface--source-region-id-set source) buffer))))
(defun ebox-surface--node-key (node)
"Return NODE's namespaced sibling key, or nil for positional identity."
(when-let ((key (plist-get node :key)))
@ -92,6 +392,40 @@
(plist-get state :region-node-table))
table))
(defun ebox-surface--object-region-table (region-objects)
"Return an object-to-region table from REGION-OBJECTS."
(let ((table (make-hash-table :test 'eq)))
(maphash (lambda (region-id object)
(puthash object region-id table))
region-objects)
table))
(defun ebox-surface--node-editable-region-id (node)
"Return NODE's editable Ebox region id, or nil."
(pcase (and (listp node) (plist-get node :ebox-type))
('box (plist-get node :region-id))
((or 'flex 'grid)
(plist-get (plist-get node :box) :region-id))
('flex-item
(ebox-surface--node-editable-region-id (plist-get node :node)))
(_ nil)))
(defun ebox-surface--logical-id-region-table (state region-objects)
"Return root-scoped logical-id entries for STATE and REGION-OBJECTS."
(let ((table (make-hash-table :test 'equal)))
(maphash
(lambda (logical-id entries)
(dolist (entry entries)
(when-let* ((region-id
(ebox-surface--node-editable-region-id (car entry)))
(object (gethash region-id region-objects)))
(push (cons object region-id) (gethash logical-id table)))))
(plist-get state :selector-id-table))
(maphash (lambda (logical-id matches)
(puthash logical-id (nreverse matches) table))
table)
table))
(defun ebox-surface--hash-keys (table)
"Return TABLE keys in unspecified order."
(let (keys)
@ -106,18 +440,46 @@
(let ((ebox--region-box-table (plist-get state :region-box-table))
(ebox--scroll-global-state scroll-table)
(ebox--scroll-idle-prefetch-timers timer-table)
(ebox--smooth-scroll-state-table smooth-table))
(ebox--smooth-scroll-state-table smooth-table)
(ebox--render-runtime-revision
(plist-get state :runtime-revision))
(ebox--render-cache-table (plist-get state :render-cache))
(ebox--render-cache-signature-cache
(plist-get state :render-signature-cache))
(ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq))
(ebox--viewport-height-dependent-subtree-cache
(plist-get state :viewport-height-dependent-subtree-cache))
(ebox--flex-content-min-width-table
(plist-get state :flex-content-min-widths)))
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (&rest _) nil)))
(prog1 (ebox-render (plist-get state :root-node))
(prog1 (let ((ebox--surface-materialization-active t))
(ebox--render-layout (plist-get state :root-node)))
(plist-put state :scroll-state-table scroll-table)
(plist-put state :scroll-region-ids
(ebox-surface--hash-keys scroll-table)))))))
(defun ebox-surface--finish-runtime-state (state)
"Install post-layout runtime indexes into candidate STATE."
(setq state
(ebox--render-state-install-index
state (ebox--runtime-index (plist-get state :root-node) t)))
(let* ((root (plist-get state :root-node))
(ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-height-dependent-subtree-cache
(plist-get state :viewport-height-dependent-subtree-cache))
(axes (ebox--viewport-dependent-node-id-axes root)))
(plist-put state :viewport-dependent-node-id-axes axes)
(plist-put state :viewport-dependent-node-ids
(and axes
(delete-dups
(copy-sequence (append (car axes) (cdr axes))))))
(plist-put state :viewport-dependent-node-ids-ready t))
state)
(defun ebox-surface--role-ids-at (rendered position)
"Return namespaced Ebox role/id pairs at POSITION in RENDERED."
@ -196,10 +558,31 @@
:children fragments :capability 'content))
:capability 'content)))
(defun ebox-surface--project (context source previous-state)
"Project SOURCE in CONTEXT using optional PREVIOUS-STATE."
(let* ((root (ebox-surface--candidate-root source previous-state))
(state (ebox--new-buffer-render-state root))
(defun ebox-surface--apply-state-overrides (state overrides)
"Apply plist OVERRIDES to candidate runtime STATE and return STATE."
(while overrides
(setq state (plist-put state (pop overrides) (pop overrides))))
state)
(defun ebox-surface--project
(context source previous-state preserve-identities-p state-overrides)
"Project SOURCE in CONTEXT using PREVIOUS-STATE and identity policy.
PRESERVE-IDENTITIES-P retains existing Ebox node and region identities."
(if (stringp source)
(let ((object
(tp-object-ensure context nil ebox-surface--root-key
'ebox/string)))
(ignore object)
(tp-surface-result-create
(tp-surface-plan-create
:key ebox-surface--root-key :kind 'ebox/string
:text source :capability 'content)))
(let* ((root (ebox-surface--candidate-root
source previous-state preserve-identities-p))
(state
(ebox-surface--apply-state-overrides
(ebox--new-buffer-render-state root)
(copy-sequence state-overrides)))
(surface-root
(tp-object-ensure context nil ebox-surface--root-key 'ebox/surface))
(node-root
@ -211,20 +594,37 @@
(plist-put state :region-box-table (make-hash-table :test 'equal))
(let ((rendered (ebox-surface--render-candidate state)))
(setq state (ebox-surface--finish-runtime-state state))
(plist-put state :region-role-span-table
(ebox-buffer--build-region-role-span-template rendered))
(plist-put state :box-extent-template
(ebox--build-box-extent-template rendered))
(plist-put state :scroll-content-span-template
(let ((ebox--scroll-global-state
(plist-get state :scroll-state-table)))
(ebox--build-scroll-content-span-template rendered)))
(let* ((node-objects (ebox-surface--node-object-table objects-by-node))
(region-objects
(ebox-surface--region-object-table state node-objects)))
(plist-put state :surface-node-object-table node-objects)
(plist-put state :region-surface-object-table region-objects)
(plist-put state :surface-object-region-table
(ebox-surface--object-region-table region-objects))
(plist-put state :logical-id-region-table
(ebox-surface--logical-id-region-table
state region-objects))
(tp-surface-result-create
(ebox-surface--surface-plan
context surface-root rendered region-objects)
state)))))
state))))))
(defun ebox-surface--producer (source &optional previous-state)
"Return a TP producer for Ebox SOURCE and optional PREVIOUS-STATE."
(defun ebox-surface-producer
(source &optional previous-state preserve-identities-p state-overrides)
"Return a TP producer for Ebox SOURCE and optional PREVIOUS-STATE.
PRESERVE-IDENTITIES-P retains existing Ebox node and region identities.
STATE-OVERRIDES augments the candidate Ebox runtime state."
(lambda (context)
(ebox-surface--project context source previous-state)))
(ebox-surface--project
context source previous-state preserve-identities-p state-overrides)))
(provide 'ebox-surface)

129
ebox.el
View File

@ -2636,8 +2636,10 @@ ROOT-START, and avoids live extent scans for prepared root publications."
table))
(defun ebox--install-buffer-scroll-content-span-template
(buffer template start)
"Install scroll marker TEMPLATE in BUFFER relative to START."
(buffer template start &optional defer-window-region-set)
"Install scroll marker TEMPLATE in BUFFER relative to START.
When DEFER-WINDOW-REGION-SET is non-nil, postpone computing the visible
region set until the first scroll operation."
(when (and (buffer-live-p buffer) (hash-table-p template))
(let ((region-set (ebox--buffer-region-id-set buffer))
(seen (make-hash-table :test 'equal))
@ -2654,7 +2656,7 @@ ROOT-START, and avoids live extent scans for prepared root publications."
(cons (+ start (cadr entry))
(+ start (cddr entry)))))
entries)
t)))
defer-window-region-set)))
template)
(maphash
(lambda (region-id state)
@ -6873,7 +6875,8 @@ Defaults to the current buffer."
(defun ebox--cleanup-current-buffer ()
"Remove Ebox runtime state owned by the current buffer."
(ebox--clear-buffer-runtime-state (current-buffer)))
(ebox--clear-buffer-runtime-state (current-buffer))
(setq-local ebox-surface--buffer-surface nil))
(defun ebox--foreign-region-in-range-p (start end region-id)
"Return non-nil if START..END contains another ebox region."
@ -7192,10 +7195,71 @@ are settled in the same update transaction."
(memq key '(:width :min-width :max-width)))
changed-keys)))
(defun ebox--surface-region-update (handle props)
"Apply PROPS to surface-scoped region HANDLE through one TP commit."
(pcase-let* ((`(,buffer . ,region-id)
(ebox-selector--region-target handle))
(state (ebox--buffer-render-state buffer))
(box (gethash region-id
(plist-get state :region-box-table)))
(expanded (ebox--expand-plist props))
(analysis (ebox--region-update-analysis box expanded)))
(dolist (key (plist-get analysis :unknown-keys))
(message "ebox-region-update: unknown key %S (ignored)" key))
(if (not (plist-get analysis :changed-p))
(let ((report
(ebox--update-report
nil 'no-op :region-handle handle :constraint-source 'region
:runtime-published nil :runtime-revision
(plist-get state :runtime-revision)
:surface-revision
(tp-surface-revision (plist-get state :surface))
:dirty-count 0 :patch-count 0 :patch-ops nil)))
(plist-put state :last-update-report report)
report)
(let* ((candidate-root
(ebox-tree-copy-node-structure
(plist-get state :root-node)))
(candidate-box
(ebox--root-region-box candidate-root region-id))
changed-keys)
(unless candidate-box
(user-error "Ebox region handle no longer resolves to a box"))
(cl-loop for (key value) on expanded by #'cddr
for target-key = (ebox--region-update-normalize-property key)
when (or (eq target-key :content)
(memq target-key ebox--region-update-longhand-props))
unless (equal (ebox-get candidate-box target-key) value)
do (ebox-put candidate-box target-key value)
and do (push target-key changed-keys))
(setq changed-keys (nreverse changed-keys))
(let ((ebox-viewport-width (plist-get state :viewport-width))
(ebox-viewport-height (plist-get state :viewport-height)))
(let ((surface
(ebox-surface-mount-buffer
buffer candidate-root
(list :region-handle handle
:constraint-source 'region
:dirty-count 1
:dirty-keys changed-keys
:dirty-kinds
(list (ebox--region-update-dirty-kind changed-keys)))
nil t
(list :viewport-width (plist-get state :viewport-width)
:viewport-height (plist-get state :viewport-height)
:detached-identity-history
(plist-get state :detached-identity-history)))))
(plist-get (tp-surface-client-state surface)
:last-update-report)))))))
;;;###autoload
(defun ebox-region-update (region-id &rest props)
"Update one or more properties of the box identified by REGION-ID.
REGION-ID may also be an opaque handle returned by `ebox-region-resolve'.
Handles are surface-scoped and are the preferred form when one declarative
source is mounted in more than one buffer.
PROPS accepts the same shorthand and longhand keywords as `ebox-create'.
Properties are expanded through `ebox-property-rules', written to the box
plist, then rendered once. Unsupported pseudo properties such as
@ -7206,9 +7270,13 @@ updates return a `no-op' report.
Examples:
(ebox-region-update id :content \"Hello\")
(ebox-region-update (ebox-region-resolve buffer \"status\")
:content \"Ready\")
(ebox-region-update id :padding (list 1 2) :border-color \"red\")
(ebox-region-update id :content \"Hi\" :width 30 :color \"white\")"
(ebox--with-render-gc
(if (ebox-region-handle-p region-id)
(ebox--surface-region-update region-id props)
(ebox--with-validated-display-cache
(catch 'ebox-region-update-result
(let* ((inhibit-read-only t)
@ -7513,7 +7581,7 @@ Examples:
(setq transaction-completed t))
(unless transaction-completed
(restore-model)))
result-report)))))))
result-report))))))))
(defvar ebox-scroll-map nil
"Keymap for scroll interaction within ebox.")
@ -7582,34 +7650,13 @@ NODE can be any box/concat/stack node created by `ebox-create',
`ebox-concat', or `ebox-stack', or a pre-rendered string."
(declare (indent 1))
(unless (stringp node)
;; Validate opaque host identities before clearing an existing target.
;; Validate opaque host identities before preparing a target generation.
(ebox-tree-validate-host-refs node))
(let* ((runtime-root
(if (stringp node)
node
(ebox--prepare-buffer-runtime-root
node buffer-or-name)))
(buffer (get-buffer-create buffer-or-name)))
(let ((buffer (get-buffer-create buffer-or-name)))
(ebox--with-render-gc
(ebox-surface-mount-buffer buffer node)
(with-current-buffer buffer
(let ((inhibit-read-only t))
(ebox--clear-buffer-runtime-state buffer)
(erase-buffer)
(progn
(if (stringp runtime-root)
(insert runtime-root)
(ebox--set-buffer-render-state buffer runtime-root)
(condition-case err
(insert
(ebox--with-buffer-render-context buffer
(ebox-render runtime-root)))
(error
(ebox--clear-buffer-render-state buffer)
(signal (car err) (cdr err)))))
(unless (stringp runtime-root)
(ebox--refresh-buffer-scroll-content-markers buffer)
(ebox-buffer-refresh-region-role-spans buffer)))
(goto-char (point-min)))
(goto-char (point-min))
(add-hook 'kill-buffer-hook #'ebox--cleanup-current-buffer nil t)
(ebox-buffer-mode 1)
(read-only-mode 1)
@ -7741,16 +7788,26 @@ it must not run application code. An error rolls the Ebox publication back.
Return the successful publication report stored by `ebox-buffer-update-report'."
(unless (or (null after-publication) (functionp after-publication))
(signal 'wrong-type-argument (list 'functionp after-publication)))
(let ((ebox-incremental--after-declarative-publication
(or after-publication
ebox-incremental--after-declarative-publication)))
(if (ebox-candidate-p next-root)
(ebox-incremental-commit-candidate buffer-or-name next-root)
(let ((buffer (get-buffer buffer-or-name)))
(unless (buffer-live-p buffer)
(error "Ebox declarative commit requires an existing live buffer: %S"
buffer-or-name))
(ebox-incremental-commit buffer next-root)))))
(let* ((commit-input
(if (ebox-candidate-p next-root)
(ebox-incremental-consume-candidate buffer next-root)
(ebox-incremental-prepare-root-commit buffer next-root)))
(source (plist-get commit-input :root))
(callback
(or after-publication
ebox-incremental--after-declarative-publication))
(surface
(ebox-surface-mount-buffer
buffer source
(plist-get commit-input :report-base)
callback
(plist-get commit-input :preserve-identities-p)
(plist-get commit-input :state-overrides))))
(plist-get (tp-surface-client-state surface) :last-update-report))))
;;;###autoload
(defun ebox-rerender-buffer-with-context

View File

@ -82,6 +82,18 @@
(and (not ambiguous) candidate)))))
(ebox-buffer-update-report target)))
(defun ebox-test--runtime-root (buffer)
"Return BUFFER's surface-owned runtime root."
(plist-get (ebox--buffer-render-state buffer) :root-node))
(defun ebox-test--selector-node (buffer selector)
"Return the first runtime node matching SELECTOR in BUFFER."
(plist-get (car (ebox-selector-query-buffer buffer selector)) :node))
(defun ebox-test--selector-node-id (buffer selector)
"Return the first runtime node id matching SELECTOR in BUFFER."
(plist-get (car (ebox-selector-query-buffer buffer selector)) :node-id))
(ert-deftest ebox-scroll-window-lookahead-accepts-root-reflow-override ()
"Root reflow should be able to defer all hidden lookahead work."
(let ((ebox-scroll-lazy-prefix-lookahead-lines 8)
@ -371,7 +383,7 @@
(should-not (gethash (plist-get right :region-id) template))))
(ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable ()
"First live mount should copy SOURCE while preserving stable source ids."
"Live publication should update a surface copy without mutating SOURCE."
(ebox-test--reset-runtime-state)
(let* ((source (ebox-build
'(box :id "root" :content "before\nhidden" :width (120)
@ -381,114 +393,67 @@
(progn
(should-not (plist-member source :region-id))
(ebox-render-to-buffer buffer source)
(let* ((runtime-root (ebox--buffer-root-node buffer))
(source-ids (ebox-region-ids source))
(region-id (car source-ids))
(runtime-box
(gethash region-id
(ebox--buffer-region-box-table buffer))))
(should source-ids)
(should (equal (ebox-region-ids source) source-ids))
(let* ((runtime-root (ebox-test--runtime-root buffer))
(handle (ebox-region-resolve buffer "root")))
(should-not (eq runtime-root source))
(should (eq runtime-root runtime-box))
(should (eq (gethash region-id ebox--region-box-table)
runtime-box))
(let ((ebox--region-update-buffer-hint buffer))
(ebox-region-update region-id
(ebox-region-update handle
:content "after\nhidden"
:color "#222222"
:width '(140)
:scroll-offset 1))
(should (equal (ebox-region-ids source) source-ids))
:width '(140))
(setq runtime-root (ebox-test--runtime-root buffer))
(should-not (plist-member source :region-id))
(should-not (plist-member source :node-id))
(should (equal (ebox-get source :content) "before\nhidden"))
(should (equal (ebox-get source :color) "#111111"))
(should (equal (ebox-get source :width) 120))
(should (equal (ebox-get source :scroll-offset) 0))
(should (equal (ebox-get runtime-box :content) "after\nhidden"))
(should (equal (ebox-get runtime-box :color) "#222222"))
(should (equal (ebox-get runtime-box :width) 140))
(should (equal (ebox-get runtime-box :scroll-offset) 1))
(should (= (plist-get (ebox--scroll-get-state region-id)
:scroll-offset)
1))
(should (equal (ebox-get runtime-root :content) "after\nhidden"))
(should (equal (ebox-get runtime-root :color) "#222222"))
(should (equal (ebox-get runtime-root :width) 140))
(with-current-buffer buffer
(should (string-match-p "hidden"
(should (string-match-p "after"
(buffer-string))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-live-mount-rejects-second-owner-before-target-cleanup ()
"Second live mount of retained ids should fail without touching target state."
(ert-deftest ebox-live-mount-allows-one-source-on-independent-surfaces ()
"One immutable source should mount and update independently in two buffers."
(ebox-test--reset-runtime-state)
(let* ((source (ebox-build
'(box :id "root" :content "owned\nhidden" :width (120)
:height 1 :overflow scroll)))
(_source-ids (ebox-region-ids source))
(retained-copy (copy-tree source))
(missing-target-name (generate-new-buffer-name " *ebox-live-missing*"))
owner-buffer target-buffer
owner-state owner-root owner-region-id owner-scroll-state
owner-region-box owner-extents owner-timer
target-state target-text)
first-buffer second-buffer)
(unwind-protect
(progn
(setq owner-buffer
(setq first-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-owner*")
(generate-new-buffer-name " *ebox-live-first*")
source))
(setq target-buffer
(setq second-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-target*")
(ebox-create :content "target stays" :width 120)))
(setq owner-state (ebox--buffer-render-state owner-buffer)
owner-root (plist-get owner-state :root-node)
owner-region-id (car (ebox-region-ids source))
owner-scroll-state (ebox--scroll-get-state owner-region-id)
owner-region-box (gethash owner-region-id ebox--region-box-table)
owner-extents (gethash owner-region-id ebox--box-extents)
target-state (ebox--buffer-render-state target-buffer))
(setq owner-timer (run-with-timer 3600 nil #'ignore))
(puthash owner-region-id owner-timer
ebox--scroll-idle-prefetch-timers)
(with-current-buffer target-buffer
(setq target-text (buffer-string)))
(dolist (root (list source retained-copy))
(should-error
(ebox-render-to-buffer target-buffer root)
:type 'user-error)
(should (eq (ebox--buffer-render-state owner-buffer) owner-state))
(should (eq (plist-get owner-state :root-node) owner-root))
(should (eq (ebox--scroll-get-state owner-region-id)
owner-scroll-state))
(should (eq (gethash owner-region-id ebox--region-box-table)
owner-region-box))
(should (eq (gethash owner-region-id ebox--box-extents)
owner-extents))
(should (eq (gethash owner-region-id
ebox--scroll-idle-prefetch-timers)
owner-timer))
(should (eq (ebox--buffer-render-state target-buffer)
target-state))
(with-current-buffer target-buffer
(should (equal-including-properties (buffer-string)
target-text)))))
(should-error
(ebox-render-to-buffer missing-target-name retained-copy)
:type 'user-error)
(should-not (get-buffer missing-target-name))
(dolist (buffer (list owner-buffer target-buffer))
(generate-new-buffer-name " *ebox-live-second*")
source))
(let ((first-handle (ebox-region-resolve first-buffer "root"))
(second-handle (ebox-region-resolve second-buffer "root")))
(should-not (eq first-handle second-handle))
(ebox-region-update first-handle :content "first changed")
(with-current-buffer first-buffer
(should (string-match-p "first changed" (buffer-string))))
(with-current-buffer second-buffer
(should-not (string-match-p "first changed" (buffer-string))))
(ebox-region-update second-handle :content "second changed")
(with-current-buffer second-buffer
(should (string-match-p "second changed" (buffer-string))))
(should (equal (ebox-get source :content) "owned\nhidden"))
(should-not (plist-member source :region-id))))
(dolist (buffer (list first-buffer second-buffer))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))
(when (timerp owner-timer)
(cancel-timer owner-timer))
(when-let ((buffer (get-buffer missing-target-name)))
(kill-buffer buffer)))))
(kill-buffer buffer))))))
(ert-deftest ebox-live-mount-releases-old-source-after-target-replacement ()
"Replacing a target root should release the old source ids for reuse."
(ert-deftest ebox-live-mount-kill-invalidates-handle ()
"Killing a surface buffer should invalidate its region handles."
(ebox-test--reset-runtime-state)
(let* ((old-source (ebox-create :content "old" :width 120))
(new-source (ebox-create :content "new" :width 120))
(let* ((old-source (ebox-create :id "old" :content "old" :width 120))
primary-buffer secondary-buffer)
(unwind-protect
(progn
@ -496,19 +461,20 @@
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-primary*")
old-source))
(ebox-render-to-buffer primary-buffer new-source)
(let ((old-handle (ebox-region-resolve primary-buffer "old")))
(kill-buffer primary-buffer)
(setq primary-buffer nil)
(should-error (ebox-region-update old-handle :content "stale")
:type 'user-error))
(setq secondary-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-secondary*")
old-source))
(kill-buffer secondary-buffer)
(setq secondary-buffer nil)
(setq secondary-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-secondary*")
old-source))
(should (eq (ebox--region-buffer (car (ebox-region-ids old-source)))
secondary-buffer)))
(ebox-region-update (ebox-region-resolve secondary-buffer "old")
:content "reused")
(with-current-buffer secondary-buffer
(should (string-match-p "reused" (buffer-string))))
(should (equal (ebox-get old-source :content) "old")))
(dolist (buffer (list primary-buffer secondary-buffer))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))))
@ -565,7 +531,8 @@
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node))
(let ((ids (ebox--runtime-node-ids node)))
(let ((ids (ebox--runtime-node-ids
(ebox-test--runtime-root buffer))))
(should (> (length ids) 1))
(dolist (node-id ids)
(should (ebox--buffer-runtime-node buffer node-id)))))
@ -575,24 +542,26 @@
(ert-deftest ebox-region-render-owner-index-maps-boxes-and-flex-wrapper ()
"Region lookup should map each box to its smallest renderable owner."
(ebox-test--reset-runtime-state)
(let* ((plain (ebox-create :content "Plain" :width '(80)))
(flex-child (ebox-create :content "Flex child" :width '(80)))
(let* ((plain (ebox-create :id "plain" :content "Plain" :width '(80)))
(flex-child (ebox-create :id "child" :content "Flex child"
:width '(80)))
(flex (ebox-flex :width '(120) :padding 1 flex-child))
(flex-wrapper (plist-get flex :box))
(layout (ebox-column plain flex)))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(runtime-root (ebox-test--runtime-root buffer))
(runtime-plain (car (plist-get runtime-root :children)))
(runtime-flex (cadr (plist-get runtime-root :children)))
(runtime-wrapper (plist-get runtime-flex :box))
(runtime-child (car (plist-get runtime-flex :children)))
(table (ebox--buffer-region-node-table buffer))
(plain-region-id (ebox--ensure-region-id plain))
(wrapper-region-id (ebox--ensure-region-id flex-wrapper))
(child-region-id (ebox--ensure-region-id flex-child))
(plain-node-id (ebox--ensure-node-id plain))
(flex-node-id (ebox--ensure-node-id flex))
(wrapper-node-id (ebox--ensure-node-id flex-wrapper))
(child-node-id (ebox--ensure-node-id flex-child))
(runtime-plain (ebox--buffer-runtime-node buffer plain-node-id))
(runtime-flex (ebox--buffer-runtime-node buffer flex-node-id))
(runtime-child (ebox--buffer-runtime-node buffer child-node-id)))
(plain-region-id (plist-get runtime-plain :region-id))
(wrapper-region-id (plist-get runtime-wrapper :region-id))
(child-region-id (plist-get runtime-child :region-id))
(plain-node-id (plist-get runtime-plain :node-id))
(flex-node-id (plist-get runtime-flex :node-id))
(wrapper-node-id (plist-get runtime-wrapper :node-id))
(child-node-id (plist-get runtime-child :node-id)))
(should (hash-table-p table))
(should (equal (gethash plain-region-id table) plain-node-id))
(should (equal (gethash wrapper-region-id table) flex-node-id))
@ -663,8 +632,9 @@
(let* ((index (ebox--runtime-index layout t))
(index-table (plist-get index :region-box-table))
(ebox--region-box-table render-table)
(ebox--surface-materialization-active t)
(ebox--scroll-global-state (make-hash-table :test 'equal)))
(ebox-render layout)
(ebox--render-layout layout)
(should (= (hash-table-count index-table)
(hash-table-count render-table)))
(maphash
@ -2387,7 +2357,8 @@
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(let* ((root-id (plist-get layout :node-id))
(let* ((root-id (plist-get (ebox-test--runtime-root buffer)
:node-id))
(snapshot (ebox--layout-snapshot buffer root-id)))
(should snapshot)
(should (eq (plist-get snapshot :type) 'concat))
@ -2623,7 +2594,9 @@
(generate-new-buffer-name " *ebox-test*")
node))
(let* ((snapshot (ebox--layout-snapshot
buffer (plist-get node :node-id)))
buffer
(plist-get (ebox-test--runtime-root buffer)
:node-id)))
(child-ids (plist-get snapshot :child-ids))
(child-snapshot (ebox--layout-snapshot buffer (car child-ids))))
(should (equal (plist-get snapshot :display) '(block flow)))
@ -2813,7 +2786,8 @@
(should (equal (plist-get report :constraint-owner-id)
'viewport))
(should (equal (plist-get report :constraint-root-node-id)
(plist-get layout :node-id)))
(plist-get (ebox-test--runtime-root buffer)
:node-id)))
(should (equal (plist-get report :dirty-kinds)
'(geometry)))))
(when (and buffer (buffer-live-p buffer))
@ -2837,6 +2811,9 @@
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let* ((runtime-root (ebox-test--runtime-root buffer))
(runtime-top (car (plist-get runtime-root :children)))
(runtime-bottom (cadr (plist-get runtime-root :children))))
(ebox-rerender-buffer-with-context buffer 90)
(let ((report (ebox-test--buffer-update-report))
(plain (with-current-buffer buffer
@ -2845,14 +2822,14 @@
(should (equal (plist-get report :patch-ops)
'(owner-rerender owner-rerender)))
(should (= (plist-get report :patch-count) 2))
(should-not (member (plist-get layout :node-id)
(should-not (member (plist-get runtime-root :node-id)
(plist-get report :owner-ids)))
(should (member (plist-get top :node-id)
(should (member (plist-get runtime-top :node-id)
(plist-get report :owner-ids)))
(should (member (plist-get bottom :node-id)
(should (member (plist-get runtime-bottom :node-id)
(plist-get report :owner-ids)))
(should (string-match-p "Top text" plain))
(should (string-match-p "Bottom text" plain))))
(should (string-match-p "Bottom text" plain)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
@ -2889,8 +2866,11 @@
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'region))
(should (eq (plist-get report :constraint-owner-type) 'box))
(should (equal (plist-get report :constraint-owner-id)
(plist-get left :node-id)))
(should
(equal (plist-get report :constraint-owner-id)
(gethash left-id
(ebox--buffer-region-node-table
(current-buffer)))))
(should (equal (plist-get report :dirty-kinds)
'(geometry)))))))
@ -3140,7 +3120,7 @@
(should (> (length dirty) 8))
(should (= (length patches) 1))
(should (equal (plist-get (car patches) :owner-id)
(plist-get layout :node-id)))
(plist-get root :node-id)))
(should (= (length source-ids) (length dirty)))
(dolist (entry dirty)
(should (member (plist-get entry :node-id) source-ids)))))
@ -3161,7 +3141,8 @@
(dirty-entry-count 0)
(original (symbol-function 'ebox--promote-to-patchable-owner))
(original-dirty-entry (symbol-function 'ebox--dirty-entry))
buffer)
buffer
root-id)
(unwind-protect
(progn
(let ((ebox-viewport-width 640))
@ -3175,6 +3156,7 @@
(source-ids
(ebox--dirty-provenance-node-ids
(plist-get (car patches) :dirty))))
(setq root-id (plist-get root :node-id))
(should (> (length dirty) 8))
(should (= (length source-ids) (length dirty)))
(dolist (entry dirty)
@ -3194,7 +3176,7 @@
(should (= (plist-get report :dirty-count) 1))
(should (= (plist-get report :patch-count) 1))
(should (equal (plist-get report :owner-ids)
(list (plist-get layout :node-id))))))
(list root-id)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
@ -3264,7 +3246,9 @@
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids)
(list (plist-get layout :node-id)))))
(list (plist-get
(ebox-test--runtime-root buffer)
:node-id)))))
(should (= slot-probe-count 0)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
@ -4980,12 +4964,9 @@
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(let* ((root-id (ebox--ensure-node-id layout))
(left-node-id (ebox--ensure-node-id left))
(right-node-id (ebox--ensure-node-id right))
(runtime-root (ebox--buffer-runtime-node buffer root-id))
(runtime-left (ebox--buffer-runtime-node buffer left-node-id))
(runtime-right (ebox--buffer-runtime-node buffer right-node-id)))
(let* ((runtime-root (ebox-test--runtime-root buffer))
(runtime-left (car (plist-get runtime-root :children)))
(runtime-right (cadr (plist-get runtime-root :children))))
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature runtime-root))
(let* ((cache (ebox--buffer-render-signature-cache buffer))
@ -5027,12 +5008,9 @@
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(let* ((root-id (ebox--ensure-node-id layout))
(scroll-node-id (ebox--ensure-node-id scroll-box))
(stable-node-id (ebox--ensure-node-id stable))
(runtime-root (ebox--buffer-runtime-node buffer root-id))
(runtime-scroll (ebox--buffer-runtime-node buffer scroll-node-id))
(runtime-stable (ebox--buffer-runtime-node buffer stable-node-id)))
(let* ((runtime-root (ebox-test--runtime-root buffer))
(runtime-scroll (car (plist-get runtime-root :children)))
(runtime-stable (cadr (plist-get runtime-root :children))))
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature runtime-root))
(let* ((cache (ebox--buffer-render-signature-cache buffer))
@ -5251,7 +5229,7 @@
(apply original arguments))))
(setq fast (ebox-render box)))
(should (= fast-path-calls 1))
(should (eq (gethash 'fixed-wrapper ebox--region-box-table) box))
(should-not (gethash 'fixed-wrapper ebox--region-box-table))
(should-not (gethash 'fixed-wrapper ebox--scroll-global-state))
(should (equal-including-properties fast generic))))
@ -5604,7 +5582,8 @@
(should (= register-count 0))
(should (= snapshot-count 0))
(should (= runtime-node-ids-count 0))
(let* ((root-node-id (plist-get layout :node-id))
(let* ((root-node-id
(plist-get (ebox-test--runtime-root buffer) :node-id))
(target-id (ebox-test--selector-region-id
buffer "#lazy-initial-06")))
(should (ebox--layout-snapshot buffer root-node-id))
@ -6550,8 +6529,10 @@
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))
(unrelated-child-id
(ebox--ensure-node-id
(car (last (ebox--node-children layout)))))
(plist-get
(car (last (ebox--node-children
(ebox-test--runtime-root (current-buffer)))))
:node-id))
(span-snapshot-calls 0)
(measure-calls 0)
(original (symbol-function 'ebox--ensure-layout-snapshot-spans))
@ -6758,8 +6739,10 @@
(ebox-region-update flex-wrapper-id :color "#7C3AED")
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'paint-patch))
(should (equal (car (plist-get (ebox-test--buffer-update-report) :owner-ids))
(ebox--ensure-node-id flex)))
(should
(equal
(car (plist-get (ebox-test--buffer-update-report) :owner-ids))
(gethash flex-wrapper-id region-node-table)))
(should (= recursive-calls 0))
(ebox-incremental-begin-batch buffer)
@ -9617,7 +9600,10 @@
:content ,(format "prefix %02d" idx)
:width (100) :height 2)))))
(limit 4)
(full-lines (ebox-string-lines (ebox-render node)))
(_index (ebox--runtime-index node t))
(full-lines
(let ((ebox--surface-materialization-active t))
(ebox-string-lines (ebox--render-layout node))))
(window (ebox--render-flex-window-lines node limit)))
(should-not (plist-get window :complete))
(should (equal (mapcar #'substring-no-properties
@ -9740,12 +9726,13 @@
(lambda (&rest args)
(cl-incf multiline-horizontal-concat-count)
(apply original-horizontal-concat args))))
(let ((window (ebox--render-flex-window-lines node 4)))
(let* ((ebox--surface-materialization-active t)
(window (ebox--render-flex-window-lines node 4)))
(should window)
(should-not (plist-get window :complete))
(should (= (length (plist-get window :lines)) 4))
(should
(equal-including-properties
(ebox-test--rendered-visually-equal-p
(ebox-lines-join (seq-take full-lines 4))
(ebox-lines-join (plist-get window :lines))))))
(should (= full-wrapper-count 0))
@ -9766,7 +9753,10 @@
collect
`(box :content ,(format "complete %02d" idx)
:width (100) :height 2)))))
(full (ebox-render node))
(_index (ebox--runtime-index node t))
(full
(let ((ebox--surface-materialization-active t))
(ebox--render-layout node)))
(full-wrapper-count 0)
(chunk-wrapper-count 0)
(original-full-wrapper
@ -9781,7 +9771,8 @@
(lambda (&rest args)
(cl-incf chunk-wrapper-count)
(apply original-chunk-wrapper args))))
(let ((window (ebox--render-flex-window-lines node 8)))
(let* ((ebox--surface-materialization-active t)
(window (ebox--render-flex-window-lines node 8)))
(should window)
(should (plist-get window :complete))
(should
@ -14010,7 +14001,8 @@
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(setq expected-viewport-ids
(ebox--viewport-dependent-node-ids layout))
(ebox--viewport-dependent-node-ids
(ebox-test--runtime-root buffer)))
(let* ((state (ebox-scroll-state root-id))
(before-lines (length (plist-get state :content-lines)))
(materialize (plist-get state :materialize-content-lines)))
@ -14733,8 +14725,9 @@
'minimal-spans))
(should (plist-get (ebox-test--buffer-update-report)
:published-buffer-spans))
(let ((expected (let ((ebox-viewport-width 160))
(ebox-render layout))))
(let ((expected
(let ((ebox-viewport-width 160))
(ebox-render (ebox-test--runtime-root buffer)))))
(with-current-buffer buffer
(should (equal-including-properties
(buffer-substring (point-min) (point-max))

View File

@ -548,9 +548,11 @@
(ert-deftest ebox-flex-reuses-measured-non-box-child-render ()
"Flex should not fully rerender a measured non-box child during one render."
(let* ((child (ebox-column
(let* ((child (plist-put
(ebox-column
(ebox-create :content "A" :width '(100))
(ebox-create :content "B" :width '(100))))
(ebox-create :content "B" :width '(100)))
:key 'measured-child))
(layout (ebox-flex :width '(240) :flex-wrap 'wrap
child
(ebox-column
@ -560,7 +562,7 @@
(original-render (symbol-function 'ebox-render)))
(cl-letf (((symbol-function 'ebox-render)
(lambda (node)
(when (eq node child)
(when (eq (plist-get node :key) 'measured-child)
(cl-incf calls))
(funcall original-render node))))
(ebox-render layout))
@ -607,9 +609,11 @@
(ert-deftest ebox-flex-reuses-natural-non-box-render-before-cross-padding ()
"Final cross padding should reuse a naturally sized non-box render."
(let* ((source (ebox-column
(let* ((source (plist-put
(ebox-column
(ebox-create :content "A" :width '(80))
(ebox-create :content "A2" :width '(80))))
(ebox-create :content "A2" :width '(80)))
:key 'natural-child))
(layout
(ebox-flex :width '(220)
(ebox-flex-item source :flex-grow 1 :flex-basis '(80))
@ -619,7 +623,7 @@
rendered)
(cl-letf (((symbol-function 'ebox-render)
(lambda (node)
(when (eq node source)
(when (eq (plist-get node :key) 'natural-child)
(cl-incf calls))
(funcall original node))))
(setq rendered (ebox-render layout)))
@ -655,14 +659,17 @@
(box :content "B" :height 1)
(box :content "C" :height 1)
(box :content "D" :height 1))))))
(root-id
(plist-get (car (ebox-selector-query-all layout "#root"))
:region-id)))
(ebox-render layout)
(let ((state (ebox-scroll-state root-id)))
(buffer (generate-new-buffer " *ebox-flex-scroll-state*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(let* ((match (car (ebox-selector-query-buffer buffer "#root")))
(state (ebox-scroll-state (plist-get match :region-id))))
(should (equal (ebox-get (plist-get state :box) :height) 2))
(should-not (plist-get state :content-lines-complete-p))
(should-not (plist-get state :render-content-prefix)))))
(should-not (plist-get state :render-content-prefix))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-flex-natural-row-padding-uses-rendered-overflow-width ()
"Natural cross padding should preserve a row item's rendered overflow width."

View File

@ -172,15 +172,14 @@
(right (ebox-create :content "B" :id "right" :width 20))
(node (ebox-grid :grid-template-columns '((20) (20)) left right))
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-grid-update*") node))
(region-id (car (ebox-region-ids left))))
(generate-new-buffer-name " *ebox-grid-update*") node)))
(unwind-protect
(progn
(should (with-current-buffer buffer
(and (string-match-p "A" (buffer-string))
(string-match-p "B" (buffer-string)))))
(with-current-buffer buffer
(ebox-region-update region-id :content "Updated"))
(ebox-region-update (ebox-region-resolve buffer "left")
:content "Updated")
(let ((updated (with-current-buffer buffer (buffer-string))))
(should (string-match-p "Updated" updated))
(should (string-match-p "B" updated))))

View File

@ -15,6 +15,7 @@
"ebox-layout.el"
"ebox-flex.el"
"ebox-grid.el"
"ebox-selector.el"
"ebox-native-reflow.el")
"Source files that currently own public autoload entry points.")
@ -126,6 +127,7 @@
("ebox-scroll-down" . "defun")
("ebox-scroll-up" . "defun")
("ebox-region-update" . "defun")
("ebox-region-resolve" . "defun")
("ebox-buffer-update-report" . "defun")
("ebox-byte-compile" . "defun")
("ebox-native-build" . "defun")

View File

@ -82,7 +82,7 @@
(node (funcall builder)))
(if projector-p
(tp-surface-materialize-string
(ebox-surface--producer node))
(ebox-surface-producer node))
(ebox-render node))))
(defun ebox-surface-test--walk-runtime (node function)
@ -141,9 +141,9 @@
(ert-deftest ebox-surface-assigns-object-identity-before-layout ()
"Every candidate runtime node should own a TP object before layout starts."
(let ((original (symbol-function 'ebox-render))
(let ((original (symbol-function 'ebox--render-layout))
checked captured)
(cl-letf (((symbol-function 'ebox-render)
(cl-letf (((symbol-function 'ebox--render-layout)
(lambda (node)
(unless checked
(setq checked t)
@ -199,7 +199,7 @@
(setq surface
(tp-surface-mount
buffer
(ebox-surface--producer
(ebox-surface-producer
(funcall (cdr (assq 'box (ebox-surface-test--fixtures)))))
'(:capability content)))
(let* ((state (tp-surface-client-state surface))
@ -228,11 +228,11 @@
(progn
(setq first
(tp-surface-mount
first-buffer (ebox-surface--producer source)
first-buffer (ebox-surface-producer source)
'(:capability content)))
(setq second
(tp-surface-mount
second-buffer (ebox-surface--producer source)
second-buffer (ebox-surface-producer source)
'(:capability content)))
(let* ((first-state (tp-surface-client-state first))
(second-state (tp-surface-client-state second))
@ -264,7 +264,7 @@
(_mount
(setq surface
(tp-surface-mount
buffer (ebox-surface--producer first-source)
buffer (ebox-surface-producer first-source)
'(:capability content))))
(first-state (tp-surface-client-state surface))
(left (ebox-surface-test--object-by-key first-state 'left))
@ -274,7 +274,7 @@
(ebox-create :key 'right :content "Right!" :width '(60))
(ebox-create :key 'left :content "Left!" :width '(60)))))
(tp-surface-update
surface (ebox-surface--producer next-source first-state))
surface (ebox-surface-producer next-source first-state))
(let ((next-state (tp-surface-client-state surface)))
(should (eq left
(ebox-surface-test--object-by-key next-state 'left)))
@ -289,6 +289,252 @@
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-render-materializes-through-tp-surface ()
"The public string renderer should materialize one ephemeral TP surface."
(ebox-surface-test--reset-render-state)
(let ((original (symbol-function 'tp-surface-materialize-string))
(calls 0))
(cl-letf (((symbol-function 'tp-surface-materialize-string)
(lambda (producer)
(cl-incf calls)
(funcall original producer))))
(should (stringp
(ebox-render
(ebox-create :content "Materialized" :width '(100))))))
(should (= calls 1))))
(ert-deftest ebox-render-is-repeatable-without-consuming-runtime-identities ()
"Ephemeral rendering should be exact and leave live identity counters alone."
(ebox-surface-test--reset-render-state)
(let* ((source (ebox-create :content "Repeatable" :width '(100)))
(first (ebox-render source))
(second (ebox-render source)))
(should (equal-including-properties first second))
(should (= ebox--region-id-counter 0))
(should (= ebox--runtime-node-id-counter 0))
(should-not (plist-member source :region-id))
(should-not (plist-member source :node-id))))
(ert-deftest ebox-render-to-buffer-mounts-one-tp-surface ()
"The public buffer renderer should expose TP's committed client state."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-public-surface*"))
surface)
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :content "Mounted" :width '(100)))
(setq surface
(with-current-buffer buffer ebox-surface--buffer-surface))
(should (tp-surface-live-p surface))
(should (eq (ebox--buffer-render-state buffer)
(tp-surface-client-state surface))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))
(should-not (tp-surface-live-p surface))))
(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)
(let* ((source
(ebox-create :key 'shared :content "Shared" :width '(100)))
(first (generate-new-buffer " *ebox-public-first*"))
(second (generate-new-buffer " *ebox-public-second*")))
(unwind-protect
(progn
(ebox-render-to-buffer first source)
(ebox-render-to-buffer second source)
(should (equal
(with-current-buffer first
(substring-no-properties (buffer-string)))
(with-current-buffer second
(substring-no-properties (buffer-string)))))
(should-not
(equal (ebox-region-ids (ebox--buffer-root-node first))
(ebox-region-ids (ebox--buffer-root-node second))))
(should-not
(eq (plist-get (ebox--buffer-root-node first) :surface-object)
(plist-get (ebox--buffer-root-node second) :surface-object)))
(should-not (plist-member source :node-id))
(should-not (plist-member source :region-id))
(should-not (plist-member source :surface-object)))
(dolist (buffer (list first second))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-commit-publishes-through-the-mounted-tp-surface ()
"Declarative commits should bypass every legacy Ebox buffer publisher."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-commit*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :key 'root :content "Before" :width '(100)))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
report)
(cl-letf (((symbol-function 'ebox-incremental-commit)
(lambda (&rest _)
(error "Legacy Ebox publisher was called"))))
(setq report
(ebox-commit
buffer
(ebox-create :key 'root :content "After"
:width '(100)))))
(should (= (tp-surface-revision surface) (1+ revision)))
(should (plist-get report :runtime-published))
(should (equal report (ebox-buffer-update-report buffer)))
(should (string-match-p "After" (with-current-buffer
buffer (buffer-string))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-commit-callback-failure-rolls-back-tp-and-ebox-state ()
"A failed publication callback should restore one shared old generation."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-rollback*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :key 'root :content "Stable" :width '(100)))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(state (tp-surface-client-state surface))
(revision (tp-surface-revision surface))
(contents (with-current-buffer
buffer
(buffer-substring (point-min) (point-max)))))
(should-error
(ebox-commit
buffer
(ebox-create :key 'root :content "Rejected" :width '(100))
(lambda (_report) (error "Reject publication"))))
(should (= (tp-surface-revision surface) revision))
(should (eq (tp-surface-client-state surface) state))
(should (equal-including-properties
(with-current-buffer
buffer
(buffer-substring (point-min) (point-max)))
contents))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-commit-killed-buffer-rollback-does-not-revive-runtime ()
"A failed commit must not restore runtime state for a killed buffer."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-killed-rollback*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :key 'root :content "Stable" :width '(100)))
(should (gethash buffer ebox--buffer-render-state-table))
(should-error
(ebox-commit
buffer
(ebox-create :key 'root :content "Rejected" :width '(100))
(lambda (_report)
(kill-buffer buffer)
(error "Reject publication after teardown"))))
(should-not (buffer-live-p buffer))
(should-not (gethash buffer ebox--buffer-render-state-table)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-logical-candidate-report-is-the-published-client-report ()
"Logical candidates should publish one report through TP client state."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-candidate*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :key 'root :host-ref 'root
:content "Before" :width '(100)))
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref
candidate 'root
(ebox-create :key 'root :host-ref 'root
:content "After" :width '(100)))
(let ((report (ebox-commit buffer candidate)))
(should (equal report (ebox-buffer-update-report buffer)))
(should (eq (plist-get report :constraint-source)
'declarative))
(should (string-match-p "After" (with-current-buffer
buffer (buffer-string)))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(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)
(let* ((source
(ebox-create :id "status" :content "Ready" :width '(100)))
(first (generate-new-buffer " *ebox-handle-first*"))
(second (generate-new-buffer " *ebox-handle-second*")))
(unwind-protect
(progn
(ebox-render-to-buffer first source)
(ebox-render-to-buffer second source)
(let ((first-handle (ebox-region-resolve first "status"))
(second-handle (ebox-region-resolve second 'status)))
(should (ebox-region-handle-p first-handle))
(should (ebox-region-handle-p second-handle))
(should-not (eq first-handle second-handle))
(ebox-region-update first-handle :content "Changed")
(should (string-match-p "Changed"
(with-current-buffer first
(buffer-string))))
(should (string-match-p "Ready"
(with-current-buffer second
(buffer-string))))))
(dolist (buffer (list first second))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-selector-query-returns-an-editable-region-handle ()
"A live selector match should carry the same handle accepted by updates."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-selector-handle*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :id "action" :content "Closed" :width '(100)))
(let* ((match (car (ebox-selector-query-buffer buffer "#action")))
(handle (plist-get match :region-handle)))
(should (ebox-region-handle-p handle))
(ebox-region-update handle :content "Open")
(should (string-match-p "Open"
(with-current-buffer buffer
(buffer-string))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-region-handle-becomes-stale-with-its-object ()
"A handle should fail after a commit removes its retained object."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-stale-handle*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :key 'old :id "old"
:content "Old" :width '(100)))
(let ((handle (ebox-region-resolve buffer "old")))
(ebox-commit
buffer
(ebox-create :key 'new :id "new"
:content "New" :width '(100)))
(should-error (ebox-region-update handle :content "Invalid")
:type 'user-error)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(provide 'ebox-surface-tests)
;;; ebox-surface-tests.el ends here