refactor(ebox): route retained updates through TP

Make TP the sole owner of live-buffer text-property publication, mount spans, scoped diff execution, and transaction rollback. Ebox now computes layout owners and retained surface plans, publishes handle/viewport/theme/scroll changes through TP, and keeps its mirrored runtime state transactionally consistent. Remove the former Ebox marker/index/patch executor instead of preserving a second mutation path.\n\nVerification:\n- make ci EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs\n- make package-lint-install EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs\n- strict byte compilation passed for 16 files\n- Ebox production has no tp-- private calls or marker writers\n- TP production has no Ebox dependency
This commit is contained in:
Kinneyzhang 2026-08-06 13:46:53 +08:00
parent cdf841232a
commit 654c824630
15 changed files with 2568 additions and 17004 deletions

View File

@ -25,7 +25,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `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` | 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-buffer-backend.el` | Propertized render-string construction, display spaces/borders, and existing-slot shaping. |
| `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, pure commit preparation, and reports. |
| `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. |
| `ebox-selector.el` | CSS-like queries over trees and runtime handles. |
@ -61,10 +61,10 @@ Caller-owned 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 and Full-Root Publication | `ebox-surface.el` plus public TP surface APIs | Ebox layout decisions or generic diff execution. |
| Surface Projection and Runtime 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. |
| Render-String Backend | `ebox-buffer-backend.el` | Live buffer writes, retained markers, style semantics, or application state. |
## Invariants
@ -79,8 +79,8 @@ Caller-owned Source Tree
- Buffer coordinates belong to the generation that produced them and must be refreshed after mutation.
- Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness.
- Loading Ebox never builds or installs the optional Rust module.
- Phase 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.
- Phase 9 is active: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Ebox computes dirty/layout ownership and joins its opaque runtime state to the same rollback-capable transaction; it has no second live buffer executor.
- Successful reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP physical operation counts, surface revision, scoped/full-root facts, and retained-object reconciliation counts.
## Grid contract

View File

@ -25,7 +25,7 @@
| `ebox-flex.el` | flex 归一化、line、剩余空间分配和 flex 渲染。 |
| `ebox-grid.el` | 轨道、隐式轨道、分数、minmax/repeat、gap、placement、span 和对齐。 |
| `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-buffer-backend.el` | 带文本属性的渲染字符串构造、display space/border 与既有 slot 整形。 |
| `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、纯 commit 准备和报告。 |
| `ebox-dsl.el` | 数据型 `.ebox` form以及向公共节点的 lowering。 |
| `ebox-selector.el` | 对树和 runtime handle 的 CSS-like 查询。 |
@ -61,10 +61,10 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
| 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 投影与整根发布 | `ebox-surface.el` 加公共 TP surface API | Ebox 布局决策或通用 diff 执行。 |
| Surface 投影与 Runtime 发布 | `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 所有权、样式语义或应用状态。 |
| 渲染字符串 Backend | `ebox-buffer-backend.el` | Live buffer 写入、retained marker、样式语义或应用状态。 |
## 不变量
@ -79,8 +79,8 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
- Buffer 坐标属于生成它的 generation变更后必须重新获取。
- Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。
- 加载 Ebox 不会构建或安装可选 Rust 模块。
- 当前已进入 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 统计。
- 当前已进入 Phase 9首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Ebox 只计算 dirty/layout owner并让不透明 runtime state 参与同一个可 rollback transaction不存在第二个 live buffer executor
- 成功报告会保留 Ebox 语义 strategy 与 planned publication scope再加入 `:publication-scope tp-surface`、TP 物理 operation 数、surface revision、scoped/full-root 事实和 retained-object reconciliation 统计。
## Grid 合同

View File

@ -9,8 +9,8 @@ This document defines the low-level publication contract. It is independent of E
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, 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.
6. TP owns generic retained-surface reconciliation, mount/index state, all live buffer diff execution, revision changes, and rollback for initial mount, declarative commit, handle update, viewport/theme update, and scroll update.
7. `ebox-buffer-backend.el` only builds and reshapes propertized render strings. It does not own live markers or a buffer mutation executor.
No layer may infer application state from visible buffer text. No public caller may mutate a published tree in place.
@ -26,14 +26,14 @@ fresh root
-> 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
-> retire superseded Ebox timers and warm the committed scroll state after success
```
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 semantic scope. Declarative full-root reports preserve that Ebox strategy and planned scope while separately recording TP's actual surface operations and revision.
The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. These names describe Ebox semantic owner scope; TP alone computes and executes the physical text/property diff. A patch must not silently widen its semantic scope. Reports preserve the Ebox strategy and planned scope while separately recording TP's actual surface operations and revision.
## Identity and coordinates

View File

@ -9,8 +9,8 @@
3. `ebox-style.el`、`ebox-measure.el` 与布局模块拥有归一化、测量和几何。
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。
6. TP 拥有首次 mount、声明式 commit、handle 更新、viewport/theme 更新和 scroll 更新的通用 retained-surface reconciliation、mount/index 状态、全部 live buffer diff 执行、revision 变更和 rollback。
7. `ebox-buffer-backend.el` 只构造和整形带文本属性的渲染字符串,不拥有 live marker 或 buffer mutation executor。
任何层都不能从可见 buffer 文本推断应用状态;公共调用者不能原地修改已经发布的树。
@ -26,14 +26,14 @@
-> 生成 TP surface plan 与 Ebox report base
-> TP 准备并原子发布 buffer 变更
-> Ebox transaction participant 发布匹配的 runtime index/report
-> 成功后退役旧 marker 与 timer
-> 成功后退役 Ebox timer并预热已提交的 scroll state
```
候选要么完整发布要么丢弃。render、TP 写入、Ebox participant 或发布回调失败时,必须保留之前的 buffer 文本、属性、TP revision、Ebox runtime identity、滚动状态和最近一次成功报告。如果目标 buffer 在发布期间被 killteardown 是最终状态rollback 绝不能把它复活。
## Patch 顺序
规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。Patch 不得静默扩大语义范围。声明式整根报告保留 Ebox strategy 与 planned scope同时单独记录 TP 的实际 surface operation 和 revision。
规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`这些名称描述的是 Ebox 的语义 owner 范围;只有 TP 负责计算并执行物理文本/属性 diff。Patch 不得静默扩大语义范围。报告保留 Ebox strategy 与 planned scope同时单独记录 TP 的实际 surface operation 和 revision。
## Identity 与坐标

View File

@ -108,7 +108,7 @@ Selectors query the rendered tree and return public match records. They do not o
(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.
The same logical id in two buffers resolves to two different handles, so updating one surface cannot accidentally mutate the other. A handle becomes stale when its retained object is removed or its buffer is killed. `ebox-region-update` accepts only a live handle; numeric region ids are internal render metadata and are not an update API.
## 8. Standalone `.ebox` files

View File

@ -108,7 +108,7 @@ Selector 查询已渲染的树并返回公共匹配记录,但不拥有应用
(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 仍作为内部/旧式更新形式保留
两个 buffer 中相同的逻辑 id 会解析为两个不同 handle因此更新一个 surface 不会误改另一个。retained object 被删除或 buffer 被 kill 后,原 handle 会变 stale。`ebox-region-update` 只接受 live handle数字 region id 只是内部渲染元数据,不是更新 API
## 8. 独立 `.ebox` 文件

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -64,8 +64,6 @@
(declare-function ebox-string-lines "ebox" (string))
(declare-function ebox-string-pixel-width "ebox-measure" (string))
(declare-function ebox-display-signature "ebox-measure" ())
(declare-function ebox--register-rendered-root-metadata
"ebox" (rendered prepared))
(defvar ebox--flex-content-min-width-table)
@ -180,9 +178,6 @@ module while loading the package."
(defvar ebox-native-reflow--build-process nil
"Live asynchronous process started by `ebox-native-build', or nil.")
(defvar ebox-native-reflow--last-owner-proof-error nil
"Most recent rejected native owner proof error.")
(defconst ebox-native-reflow--build-buffer-name "*Ebox Native Build*"
"Compilation buffer used by `ebox-native-build'.")
@ -2841,8 +2836,7 @@ typed line-width and property invariants instead of repeating them in Emacs."
decoded)
(let ((header (aref decoded 0))
(rendered (aref decoded 1))
(line-widths (aref decoded 2))
(metadata (aref decoded 3)))
(line-widths (aref decoded 2)))
(unless trusted-native-encoder-p
(ebox-native-reflow--validate-literal-properties
rendered styles (plist-get header :complete)
@ -2855,9 +2849,6 @@ typed line-width and property invariants instead of repeating them in Emacs."
unless (= (ebox-string-pixel-width line)
(aref line-widths index))
do (error "Native reflow tape line width mismatch"))))
(when (and trusted-native-encoder-p metadata
(fboundp 'ebox--register-rendered-root-metadata))
(ebox--register-rendered-root-metadata rendered metadata))
rendered))))
(defun ebox-native-reflow-take-layout
@ -2918,57 +2909,6 @@ template ids are expanded before returning."
payload (plist-get package :styles) expected t
(plist-get package :property-templates))))))
(defun ebox-native-reflow-render-owner-proof
(node snapshot _dirty context)
"Return a native proof render for NODE and CONTEXT, or nil when unsupported."
(when (and (not (plist-get context :root-owner-p))
(ebox-native-reflow--native-node-supported-p node)
(ebox-native-reflow-layout-ready-p)
(>= (length (plist-get snapshot :buffer-spans)) 2))
(let* ((details-function (plist-get context :details-function))
(details
(if (functionp details-function)
(funcall details-function)
context))
(stored-viewport-height (plist-get details :viewport-height))
(viewport-height
(cond
((and (numberp stored-viewport-height)
(> stored-viewport-height 0))
(truncate stored-viewport-height))
((not (plist-get details :viewport-height-dependent-p))
(max 1 (length (plist-get snapshot :buffer-spans))))))
(node-type (and (listp node) (plist-get node :ebox-type)))
(exact-slot-width (plist-get details :parent-content-width))
(viewport-width-known-p
(and (numberp exact-slot-width) (> exact-slot-width 0)))
(slot-width
(or (and viewport-width-known-p
(truncate exact-slot-width))
(plist-get details :slot-width-placeholder))))
(when (and (plist-get details :side-effects-safe-p)
(integerp viewport-height) (> viewport-height 0)
(integerp slot-width) (> slot-width 0))
(condition-case err
(let ((frame
(list :key 1
:viewport-width slot-width
:viewport-width-known viewport-width-known-p
:viewport-height viewport-height
:runtime-revision
(or (plist-get details :runtime-revision) 0)
:context-hash 0
:complete t
:root-metadata nil)))
(when (and viewport-width-known-p (eq node-type 'box))
(setq frame (plist-put frame :root-width slot-width)))
(setq ebox-native-reflow--last-owner-proof-error nil)
(ebox-native-reflow-render-proof-sync node frame))
(error
(setq ebox-native-reflow--last-owner-proof-error
(error-message-string err))
(signal (car err) (cdr err))))))))
(defun ebox-native-reflow-submit (session generation frames)
"Submit FRAMES for SESSION GENERATION without waiting for results."
(unless (and (integerp generation) (>= generation 0))
@ -3549,9 +3489,6 @@ When FULL-P is non-nil, query the complete restoration job."
(signal (car err) (cdr err))))))
(unless (or (stringp result) (plist-get result :native-patch))
(error "Native reflow result disappeared before materialization"))
(when (and (stringp result)
(fboundp 'ebox--rendered-root-metadata))
(ebox--rendered-root-metadata result t))
result))
(defun ebox-native-reflow--materialize-next-ready-frame (preparation)

View File

@ -24,7 +24,6 @@
(declare-function ebox-incremental-begin-batch "ebox-incremental" (buffer))
(declare-function ebox-incremental-flush "ebox-incremental" (buffer))
(declare-function ebox-region-update "ebox" (region-id &rest props))
(defvar ebox--region-update-buffer-hint)
(cl-defstruct
(ebox-region-handle
@ -549,8 +548,7 @@ tree-query handle fields."
(defun ebox-selector--update-region (handle props)
"Apply PROPS to HANDLE's region through `ebox-region-update'."
(let ((ebox--region-update-buffer-hint (plist-get handle :buffer)))
(apply #'ebox-region-update (plist-get handle :region-id) props)))
(apply #'ebox-region-update (plist-get handle :region-handle) props))
;;;###autoload
(defun ebox-selector-update-buffer (buffer selector &rest props)

View File

@ -14,6 +14,7 @@
(require 'ebox-tree)
(require 'ebox-layout)
(require 'ebox-incremental)
(require 'tp-reactive)
(require 'tp-surface)
(defvar ebox-region-types)
@ -21,8 +22,6 @@
(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)
@ -31,39 +30,25 @@
(defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache)
(defvar ebox--flex-content-min-width-table)
(defvar ebox--scroll-window-initial-lookahead-lines-override)
(defvar ebox-viewport-width)
(defvar ebox-viewport-height)
(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--update-report
"ebox-incremental" (region-id strategy &rest props))
(declare-function ebox-incremental--finalize-declarative-scroll-publication
"ebox-incremental" (scroll-keys))
"ebox-incremental" (scroll-keys &optional prefetch-delay))
(declare-function ebox--scroll-schedule-idle-prefetch
"ebox" (region-id &optional delay))
(declare-function ebox--scroll-clear-state "ebox" (region-id))
@ -72,6 +57,113 @@
(defvar-local ebox-surface--buffer-surface nil
"Live TP content surface mounted for the current Ebox buffer.")
(cl-defstruct (ebox-surface--signals
(:constructor ebox-surface--make-signals))
"TP signals carrying one mounted Ebox surface's host context."
buffer viewport-width viewport-height display scroll)
(defvar-local ebox-surface--context-signals nil
"Buffer-scoped TP signals consumed by the mounted Ebox producer.")
(defun ebox-surface--signals-live-p (signals)
"Return non-nil when every signal in SIGNALS is live."
(and (ebox-surface--signals-p signals)
(buffer-live-p (ebox-surface--signals-buffer signals))
(cl-every
#'tp-signal-live-p
(list (ebox-surface--signals-viewport-width signals)
(ebox-surface--signals-viewport-height signals)
(ebox-surface--signals-display signals)
(ebox-surface--signals-scroll signals)))))
(defun ebox-surface--dispose-signals (signals)
"Dispose live TP SIGNALS owned by an abandoned Ebox mount."
(when (ebox-surface--signals-p signals)
(dolist (signal
(list (ebox-surface--signals-viewport-width signals)
(ebox-surface--signals-viewport-height signals)
(ebox-surface--signals-display signals)
(ebox-surface--signals-scroll signals)))
(when (tp-signal-live-p signal)
(tp-signal-dispose signal)))
(when-let ((buffer (ebox-surface--signals-buffer signals)))
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when (eq ebox-surface--context-signals signals)
(setq-local ebox-surface--context-signals nil)))))))
(defun ebox-surface--scroll-offsets (table)
"Return immutable sorted scroll offsets copied from TABLE."
(let (offsets)
(when (hash-table-p table)
(maphash
(lambda (region-id state)
(push (cons region-id (or (plist-get state :scroll-offset) 0))
offsets))
table))
(sort offsets (lambda (left right) (< (car left) (car right))))))
(defun ebox-surface--context-values
(buffer old-state state-overrides)
"Return BUFFER context values after OLD-STATE and STATE-OVERRIDES."
(cl-labels
((value (key fallback)
(if (plist-member state-overrides key)
(plist-get state-overrides key)
(if (plist-member old-state key)
(plist-get old-state key)
fallback))))
(let ((scroll-table
(value :scroll-state-table
(plist-get old-state :scroll-state-table))))
(list :viewport-width
(value :viewport-width ebox-viewport-width)
:viewport-height
(value :viewport-height ebox-viewport-height)
:display-signature
(value :display-signature
(with-current-buffer buffer
(ebox--current-display-signature)))
:scroll-offsets
(ebox-surface--scroll-offsets scroll-table)))))
(defun ebox-surface--ensure-signals (buffer values)
"Return BUFFER's context signals for VALUES and whether they were created."
(let ((signals
(with-current-buffer buffer ebox-surface--context-signals)))
(if (ebox-surface--signals-live-p signals)
(cons signals nil)
(when signals (ebox-surface--dispose-signals signals))
(setq signals
(ebox-surface--make-signals
:buffer buffer
:viewport-width
(tp-signal-create (plist-get values :viewport-width)
:scope buffer)
:viewport-height
(tp-signal-create (plist-get values :viewport-height)
:scope buffer)
:display
(tp-signal-create (plist-get values :display-signature)
:scope buffer)
:scroll
(tp-signal-create (plist-get values :scroll-offsets)
:scope buffer)))
(with-current-buffer buffer
(setq-local ebox-surface--context-signals signals))
(cons signals t))))
(defun ebox-surface--stage-signal-values (signals values)
"Stage VALUES into TP context SIGNALS in the active transaction."
(tp-signal-set (ebox-surface--signals-viewport-width signals)
(plist-get values :viewport-width))
(tp-signal-set (ebox-surface--signals-viewport-height signals)
(plist-get values :viewport-height))
(tp-signal-set (ebox-surface--signals-display signals)
(plist-get values :display-signature))
(tp-signal-set (ebox-surface--signals-scroll signals)
(plist-get values :scroll-offsets)))
(defun ebox-surface--live-buffer-surface (buffer)
"Return BUFFER's live Ebox TP surface, or nil."
(when (buffer-live-p buffer)
@ -79,33 +171,49 @@
(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-buffer-mounted-p (buffer)
"Return non-nil when BUFFER has a live Ebox TP surface."
(not (null (ebox-surface--live-buffer-surface buffer))))
(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-region-mounts (buffer region-id &optional roles)
"Return REGION-ID's TP-owned output mounts in BUFFER.
When ROLES is non-nil, keep only direct mounts owning a listed Ebox role.
Without ROLES, include descendant output attached to the region's object.
The returned ranges are numeric snapshots; TP retains marker ownership."
(when-let* ((surface (ebox-surface--live-buffer-surface buffer))
(state (tp-surface-client-state surface))
(table (plist-get state :region-surface-object-table))
(object (gethash region-id table)))
(cl-remove-if-not
(lambda (mount)
(let ((tags (plist-get mount :tags)))
(if roles
(and (equal (plist-get tags :ebox/region-id) region-id)
(cl-intersection roles (plist-get tags :ebox/roles)))
(or (equal (plist-get tags :ebox/region-id) region-id)
(plist-get tags :ebox/descendant-output)))))
(tp-object-mounts object))))
(defun ebox-surface-region-bounds (buffer region-id &optional roles)
"Return numeric bounds for REGION-ID's TP mounts in BUFFER.
ROLES has the same filtering meaning as in `ebox-surface-region-mounts'."
(when-let ((mounts (ebox-surface-region-mounts buffer region-id roles)))
(cons (apply #'min (mapcar (lambda (mount) (plist-get mount :start)) mounts))
(apply #'max (mapcar (lambda (mount) (plist-get mount :end)) mounts)))))
(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--bind-scroll-states-to-buffer (state buffer)
"Bind every semantic scroll state in STATE to its owning BUFFER."
(when-let ((table (plist-get state :scroll-state-table)))
(maphash
(lambda (region-id scroll-state)
(puthash region-id (plist-put scroll-state :buffer buffer) table))
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))
@ -114,6 +222,8 @@
(or (plist-get tp-report :property-operations) 0))
(patch-count (+ text-operations property-operations))
(report (copy-sequence report-base))
(scoped-p (and (not (plist-get tp-report :full-root))
(> (or (plist-get tp-report :scope-count) 0) 0)))
(planned-publication-scope (plist-get report :publication-scope)))
(dolist (entry
`((:runtime-published . t)
@ -121,7 +231,12 @@
(:surface-revision . ,(tp-surface-revision surface))
(:publication-scope . tp-surface)
(:planned-publication-scope . ,planned-publication-scope)
(:tp-render-scope . surface-plan)
(:tp-render-scope . ,(if scoped-p 'objects 'surface-plan))
(:tp-full-root . ,(plist-get tp-report :full-root))
(:tp-scope-count . ,(plist-get tp-report :scope-count))
(:tp-scope-range-count
. ,(plist-get tp-report :scope-range-count))
(:tp-scope-fallback . ,(plist-get tp-report :scope-fallback))
(:tp-operation-count . ,patch-count)
(:tp-transaction-id . ,(plist-get tp-report :transaction-id))
(:tp-text-operations . ,text-operations)
@ -159,8 +274,8 @@
"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)
(let (new-state region-snapshot scroll-snapshot
old-surface old-mirror region-keys scroll-keys)
(tp-transaction-participate
(list 'ebox/runtime buffer)
(lambda ()
@ -168,8 +283,6 @@ runs after TP and Ebox point at the same candidate generation."
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)
@ -188,6 +301,7 @@ runs after TP and Ebox point at the same candidate generation."
(plist-put new-state :surface surface)
(plist-put new-state :runtime-revision
(1- (tp-surface-revision surface)))
(ebox-surface--bind-scroll-states-to-buffer new-state buffer)
(when report-base
(plist-put new-state :last-update-report
(ebox-surface--commit-report
@ -203,21 +317,12 @@ runs after TP and Ebox point at the same candidate generation."
(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
@ -228,8 +333,6 @@ runs after TP and Ebox point at the same candidate generation."
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)))
@ -238,20 +341,7 @@ runs after TP and Ebox point at the same candidate generation."
(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)))))
(ebox--smooth-scroll-stop region-id)))))))
(defun ebox-surface-mount-buffer
(buffer source &optional report-base after-publication
@ -262,6 +352,12 @@ 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)))
(context-values
(ebox-surface--context-values buffer old-state state-overrides))
(signals-result
(ebox-surface--ensure-signals buffer context-values))
(signals (car signals-result))
(signals-created-p (cdr signals-result))
(preserve-identities-p
(or preserve-identities-p
(and (null old-state)
@ -269,24 +365,240 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime."
source buffer))))
(producer
(ebox-surface-producer
source old-state preserve-identities-p state-overrides)))
source old-state preserve-identities-p state-overrides signals))
success)
(unwind-protect
(progn
(tp-with-transaction
(ebox-surface--stage-signal-values signals context-values)
(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))
(setq success t)
(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-incremental--finalize-declarative-scroll-publication
scroll-keys))
surface)
(when (and signals-created-p (not success))
(ebox-surface--dispose-signals signals)))))
(defun ebox-surface--viewport-report (state width height axes strategy)
"Return a viewport report for STATE, WIDTH, HEIGHT, AXES, and STRATEGY."
(let ((root (plist-get state :root-node)))
(ebox--update-report
nil strategy
:constraint-source 'viewport
:constraint-owner-id 'viewport
:constraint-owner-type 'viewport
:constraint-root-node-id (plist-get root :node-id)
:viewport-axes axes
:target-viewport-width width
:target-viewport-height height
:dirty-kinds (unless (eq axes 'none) '(geometry))
:dirty-count (if (eq axes 'none) 0 1)
:patch-count (if (eq axes 'none) 0 1)
:patch-ops (unless (eq axes 'none) '(root-rerender))
:owner-ids (unless (eq axes 'none)
(list (plist-get root :node-id)))
:root-rerender (not (eq axes 'none)))))
(defun ebox-surface--publish-context-no-op
(buffer surface state values state-overrides report)
"Commit VALUES and no-op STATE-OVERRIDES into STATE for BUFFER and SURFACE."
(let ((signals
(with-current-buffer buffer ebox-surface--context-signals))
(old-width (plist-get state :viewport-width))
(old-height (plist-get state :viewport-height))
(old-display (plist-get state :display-signature))
(old-report (plist-get state :last-update-report)))
(unless (ebox-surface--signals-live-p signals)
(error "Ebox mounted surface has no live context signals"))
(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))
(ebox-surface--stage-signal-values signals values)
(tp-transaction-participate
(list 'ebox/context-no-op buffer)
(lambda ()
(unless (eq state (tp-surface-client-state surface))
(error "Ebox no-op context unexpectedly published a surface"))
(plist-put state :viewport-width
(plist-get state-overrides :viewport-width))
(plist-put state :viewport-height
(plist-get state-overrides :viewport-height))
(plist-put state :display-signature
(plist-get state-overrides :display-signature))
(plist-put state :last-update-report report))
(lambda ()
(plist-put state :viewport-width old-width)
(plist-put state :viewport-height old-height)
(plist-put state :display-signature old-display)
(plist-put state :last-update-report old-report))))
report))
(defun ebox-surface-update-buffer-viewport (buffer width &optional height)
"Publish BUFFER for viewport WIDTH and optional HEIGHT through TP."
(let* ((surface (ebox-surface--live-buffer-surface buffer))
(state (and surface (tp-surface-client-state surface))))
(unless state
(error "Ebox viewport update requires a mounted TP surface"))
(let* ((old-width (plist-get state :viewport-width))
(old-height (plist-get state :viewport-height))
(target-height (or height old-height))
(display-signature
(with-current-buffer buffer
(ebox--current-display-signature)))
(display-changed
(not (equal display-signature
(plist-get state :display-signature))))
(width-changed (not (equal width old-width)))
(height-changed (not (equal target-height old-height)))
(axes (cond ((and width-changed height-changed) 'both)
(width-changed 'width)
(height-changed 'height)
(t 'none))))
(if (and (eq axes 'none) (not display-changed))
(let ((report
(ebox-surface--viewport-report
state width target-height axes 'no-op)))
(plist-put state :last-update-report report)
report)
(ebox-incremental--notify-before-runtime-mutation buffer 'viewport)
(unless (eq state (tp-surface-client-state surface))
(error "Ebox runtime changed during viewport update notification"))
(let ((commit-input
(ebox-incremental-prepare-viewport-commit
buffer width target-height axes display-signature)))
(if (plist-get commit-input :no-op)
(let* ((state-overrides
(plist-get commit-input :state-overrides))
(values
(ebox-surface--context-values
buffer state state-overrides)))
(ebox-surface--publish-context-no-op
buffer surface state values state-overrides
(plist-get commit-input :report-base)))
(ebox-surface-update-buffer-scoped
buffer
(plist-get commit-input :root)
(plist-get commit-input :scope-node-ids)
(plist-get commit-input :report-base)
(plist-get commit-input :state-overrides)
nil nil 0)
(plist-get (tp-surface-client-state surface)
:last-update-report)))))))
(defun ebox-surface--projection-start (context root)
"Create retained TP identities for ROOT in CONTEXT."
(let* ((surface-root
(tp-object-ensure context nil ebox-surface--root-key 'ebox/surface))
(node-root
(tp-object-ensure context surface-root
ebox-surface--nodes-key 'ebox/nodes))
(objects-by-node (make-hash-table :test 'eq)))
(tp-object-retain context node-root)
(ebox-surface--ensure-node-tree context node-root root objects-by-node)
(cons surface-root objects-by-node)))
(defun ebox-surface--projection-result
(context projection state rendered)
"Complete CONTEXT PROJECTION for STATE from RENDERED output."
(let* ((surface-root (car projection))
(objects-by-node (cdr projection))
(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 state node-objects region-objects)
state)))
(defun ebox-surface--mounted-object-for-node (state node-id)
"Return NODE-ID's nearest live retained object with mounts in STATE."
(let ((objects (plist-get state :surface-node-object-table))
(parents (plist-get state :parent-table))
object)
(while (and node-id
(progn
(setq object (and objects (gethash node-id objects)))
(or (not object) (null (tp-object-mounts object)))))
(setq node-id (and parents (gethash node-id parents))))
(unless (and object (tp-object-live-p object)
(tp-object-mounts object))
(error "Ebox scoped owner has no mounted TP object"))
object))
(defun ebox-surface--objects-for-node-ids (state node-ids)
"Return mounted retained objects in STATE for Ebox NODE-IDS."
(delete-dups
(mapcar (lambda (node-id)
(ebox-surface--mounted-object-for-node state node-id))
node-ids)))
(defun ebox-surface-update-buffer-scoped
(buffer source scope-node-ids report-base state-overrides
&optional after-publication on-mismatch scroll-prefetch-delay)
"Publish BUFFER SOURCE within SCOPE-NODE-IDS through TP.
STATE-OVERRIDES augments the isolated candidate runtime. REPORT-BASE and
AFTER-PUBLICATION have the same meaning as in `ebox-surface-mount-buffer'.
ON-MISMATCH is forwarded to TP's scoped publication policy.
SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming."
(let* ((surface (ebox-surface--live-buffer-surface buffer))
(old-state (and surface (tp-surface-client-state surface))))
(unless surface
(error "Ebox scoped update requires a mounted TP surface"))
(let ((objects
(ebox-surface--objects-for-node-ids old-state scope-node-ids)))
(unless objects
(error "Ebox scoped update requires at least one retained owner"))
(let* ((context-values
(ebox-surface--context-values buffer old-state state-overrides))
(signals-result
(ebox-surface--ensure-signals buffer context-values))
(signals (car signals-result))
(signals-created-p (cdr signals-result))
(producer
(ebox-surface-producer
source old-state t state-overrides signals))
success)
(unwind-protect
(progn
(tp-with-transaction
(ebox-surface--stage-signal-values signals context-values)
(tp-surface-update-scoped
surface objects producer
(and on-mismatch (list :on-mismatch on-mismatch)))
(ebox-surface--publish-runtime-state
buffer surface old-state report-base after-publication))
(setq success t)
(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-incremental--finalize-declarative-scroll-publication
scroll-keys scroll-prefetch-delay))
surface)
(when (and signals-created-p (not success))
(ebox-surface--dispose-signals signals)))))))
(defconst ebox-surface--root-key 'ebox/surface
"Stable TP key for an Ebox surface projection root.")
@ -432,12 +744,82 @@ identities while always discarding TP handles and render-cache attachments."
(maphash (lambda (key _value) (push key keys)) table)
keys))
(defun ebox-surface--retained-scroll-lookahead (state)
"Return lookahead needed to retain STATE's prepared lazy prefixes."
(when-let ((table (plist-get state :scroll-state-table)))
(let ((maximum 0))
(maphash
(lambda (_region-id scroll-state)
(let ((retained (length (plist-get scroll-state :content-lines)))
(offset (or (plist-get scroll-state :scroll-offset) 0))
(height (or (plist-get scroll-state :content-height) 0)))
(setq maximum
(max maximum (- retained offset height 1)))))
table)
(max 0 maximum))))
(defun ebox-surface--scroll-metadata-snapshot (table producer-region-ids)
"Return reusable lazy-scroll metadata copied from TABLE.
PRODUCER-REGION-IDS names scroll-only updates whose producer closures remain
valid because their declarative layout and viewport did not change."
(let ((snapshot (make-hash-table :test 'equal)))
(maphash
(lambda (region-id state)
(let ((preserve-producer-p
(member region-id producer-region-ids))
(producer-keys
'(:content-lines :rendered-content-lines
:content-lines-complete-p :render-content-prefix
:materialize-content-lines :region-line-bounds-index
:region-line-span-index :region-line-span-index-deferred
:rendered-region-line-span-index
:rendered-region-line-span-index-deferred
:region-line-span-hints :cache-miss-prefetch-target-lines
:native-reflow-target-prefix-p
:native-reflow-prefix-reset-p :native-reflow-visible-offset
:native-reflow-visible-lines))
metadata)
(dolist (key (append
'(:content-region-id-set :lazy-scroll-prefix-dirty
:lazy-scroll-window-refresh-required)
(and preserve-producer-p producer-keys)))
(when (if (memq key producer-keys)
(plist-member state key)
(plist-get state key))
(setq metadata
(plist-put metadata key (plist-get state key)))))
(when metadata
(puthash region-id metadata snapshot))))
table)
snapshot))
(defun ebox-surface--restore-scroll-metadata (table snapshot)
"Restore reusable lazy-scroll SNAPSHOT fields into TABLE."
(maphash
(lambda (region-id metadata)
(when-let ((state (gethash region-id table)))
(while metadata
(setq state
(plist-put state (pop metadata) (pop metadata))))
(puthash region-id state table)))
snapshot))
(defun ebox-surface--render-candidate (state)
"Render candidate STATE in isolated Ebox side tables."
(let ((scroll-table (make-hash-table :test 'equal))
(let ((scroll-table
(or (plist-get state :scroll-state-table)
(make-hash-table :test 'equal)))
(scroll-metadata
(ebox-surface--scroll-metadata-snapshot
(or (plist-get state :scroll-state-table)
(make-hash-table :test 'equal))
(plist-get state :preserve-scroll-producer-region-ids)))
(timer-table (make-hash-table :test 'equal))
(smooth-table (make-hash-table :test 'equal)))
(let ((ebox--region-box-table (plist-get state :region-box-table))
(cl-remf state :preserve-scroll-producer-region-ids)
(let ((ebox-viewport-width (plist-get state :viewport-width))
(ebox-viewport-height (plist-get state :viewport-height))
(ebox--region-box-table (plist-get state :region-box-table))
(ebox--scroll-global-state scroll-table)
(ebox--scroll-idle-prefetch-timers timer-table)
(ebox--smooth-scroll-state-table smooth-table)
@ -446,6 +828,8 @@ identities while always discarding TP handles and render-cache attachments."
(ebox--render-cache-table (plist-get state :render-cache))
(ebox--render-cache-signature-cache
(plist-get state :render-signature-cache))
(ebox--scroll-window-initial-lookahead-lines-override
(ebox-surface--retained-scroll-lookahead state))
(ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-dependent-subtree-cache
@ -458,28 +842,16 @@ identities while always discarding TP handles and render-cache attachments."
(lambda (&rest _) nil)))
(prog1 (let ((ebox--surface-materialization-active t))
(ebox--render-layout (plist-get state :root-node)))
(ebox-surface--restore-scroll-metadata
scroll-table scroll-metadata)
(plist-put state :scroll-state-table scroll-table)
(plist-put state :scroll-region-ids
(ebox-surface--hash-keys scroll-table)))))))
(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)
(ebox--render-state-install-index
state (ebox--runtime-index (plist-get state :root-node) t)))
(defun ebox-surface--role-ids-at (rendered position)
"Return namespaced Ebox role/id pairs at POSITION in RENDERED."
@ -500,56 +872,102 @@ identities while always discarding TP handles and render-cache attachments."
(cl-loop for (role . owner) in role-ids
when (equal owner region-id) collect role)))
(defun ebox-surface--attach-fragment-ancestors
(context fragment node-id state node-objects attached)
"Attach FRAGMENT in CONTEXT to NODE-ID ancestors from STATE.
NODE-OBJECTS resolves retained objects; ATTACHED prevents duplicate mounts."
(let ((parent-table (plist-get state :parent-table))
(current (gethash node-id (plist-get state :parent-table))))
(while current
(let ((object (gethash current node-objects)))
(unless object
(error "Ebox output ancestor has no TP object: %S" current))
(unless (gethash object attached)
(tp-object-attach-fragment
context object fragment '(:ebox/descendant-output t))
(puthash object t attached)))
(setq current (gethash current parent-table)))))
(defun ebox-surface--attach-fragment
(context fragment role-ids region-objects)
"Attach FRAGMENT to ROLE-IDS owners through REGION-OBJECTS in CONTEXT."
(dolist (region-id (delete-dups (mapcar #'cdr role-ids)))
(let ((object (gethash region-id region-objects)))
(unless object
(error "Ebox output references unknown region %S" region-id))
(tp-object-attach-fragment
context object fragment
(ebox-surface--region-role-tags region-id role-ids)))))
(context fragment role-ids state node-objects region-objects)
"Attach FRAGMENT to direct and ancestor Ebox owners in CONTEXT.
ROLE-IDS identifies direct rendered regions. STATE and NODE-OBJECTS extend
that ownership through the Ebox layout tree so a planned ancestor owner may
authorize every descendant output interval without exposing ranges to TP."
(let ((attached (make-hash-table :test 'eq))
(region-node-table (plist-get state :region-node-table)))
(dolist (region-id (delete-dups (mapcar #'cdr role-ids)))
(let ((object (gethash region-id region-objects))
(node-id (gethash region-id region-node-table)))
(unless (and object node-id)
(error "Ebox output references unknown region %S" region-id))
(unless (gethash object attached)
(tp-object-attach-fragment
context object fragment
(ebox-surface--region-role-tags region-id role-ids))
(puthash object t attached))
(ebox-surface--attach-fragment-ancestors
context fragment node-id state node-objects attached)))))
(defun ebox-surface--fragment-plan
(context parent rendered start end index region-objects)
"Return one plan below PARENT for RENDERED START..END at INDEX in CONTEXT."
(context parent rendered start end index role-ids
state node-objects region-objects)
"Return one plan below PARENT for RENDERED START..END at INDEX.
ROLE-IDS names the Ebox owners attached through REGION-OBJECTS in CONTEXT."
(let* ((key (cons 'ebox/fragment index))
(object (tp-object-ensure context parent key 'ebox/fragment))
(role-ids (ebox-surface--role-ids-at rendered start))
(tags (list :ebox/fragment index :ebox/role-ids role-ids)))
(ebox-surface--attach-fragment
context object role-ids region-objects)
context object role-ids state node-objects region-objects)
(tp-surface-plan-create
:key key :kind 'ebox/fragment
:text (substring rendered start end) :tags tags
:capability 'content)))
(defun ebox-surface--fragment-plans
(context parent rendered region-objects)
(context parent rendered state node-objects region-objects)
"Return property-interval plans in CONTEXT for RENDERED below PARENT."
(let ((position 0)
(limit (length rendered))
(index 0)
plans)
intervals previous-role-ids next-role-ids)
(while (< position limit)
(let ((next (or (next-property-change position rendered limit) limit)))
(push (ebox-surface--fragment-plan
context parent rendered position next index region-objects)
plans)
(setq position (max next (1+ position))
index (1+ index))))
(nreverse plans)))
(push (list :start position :end next
:role-ids
(ebox-surface--role-ids-at rendered position))
intervals)
(setq position (max next (1+ position)))))
(setq intervals (nreverse intervals))
(dolist (interval intervals)
(if-let ((roles (plist-get interval :role-ids)))
(setq previous-role-ids roles)
(plist-put interval :previous-role-ids previous-role-ids)))
(dolist (interval (reverse (copy-sequence intervals)))
(if-let ((roles (plist-get interval :role-ids)))
(setq next-role-ids roles)
(plist-put interval :role-ids
(delete-dups
(append (plist-get interval :previous-role-ids)
next-role-ids)))))
(cl-loop for interval in intervals
for fragment-index from 0
collect
(ebox-surface--fragment-plan
context parent rendered
(plist-get interval :start) (plist-get interval :end)
fragment-index (plist-get interval :role-ids)
state node-objects region-objects))))
(defun ebox-surface--surface-plan
(context surface-root rendered region-objects)
"Return CONTEXT's plan below SURFACE-ROOT for RENDERED and REGION-OBJECTS."
(context surface-root rendered state node-objects region-objects)
"Return CONTEXT's plan below SURFACE-ROOT for RENDERED and STATE.
NODE-OBJECTS and REGION-OBJECTS supply retained ownership for its fragments."
(let* ((fragment-root
(tp-object-ensure context surface-root
ebox-surface--fragments-key 'ebox/fragments))
(fragments
(ebox-surface--fragment-plans
context fragment-root rendered region-objects)))
context fragment-root rendered state node-objects region-objects)))
(tp-surface-plan-create
:key ebox-surface--root-key :kind 'ebox/surface
:children
@ -564,10 +982,36 @@ identities while always discarding TP handles and render-cache attachments."
(setq state (plist-put state (pop overrides) (pop overrides))))
state)
(defun ebox-surface--apply-scroll-offsets (root offsets)
"Apply surface-scoped scroll OFFSETS to matching boxes below ROOT."
(cl-labels
((visit (node)
(when (and (listp node) (not (stringp node)))
(when (eq (plist-get node :ebox-type) 'box)
(when-let* ((region-id (plist-get node :region-id))
(entry (assq region-id offsets)))
(ebox-put node :scroll-offset (cdr entry))))
(dolist (child (ebox-tree--children-raw node))
(visit child)))))
(visit root))
root)
(defun ebox-surface--context-axes (root)
"Return ROOT's exact viewport dependency axes without shared caches."
(let ((ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq))
(ebox--viewport-height-dependent-subtree-cache
(make-hash-table :test 'eq)))
(ebox--viewport-dependent-node-id-axes root)))
(defun ebox-surface--project
(context source previous-state preserve-identities-p state-overrides)
(context source previous-state preserve-identities-p state-overrides
signals)
"Project SOURCE in CONTEXT using PREVIOUS-STATE and identity policy.
PRESERVE-IDENTITIES-P retains existing Ebox node and region identities."
PRESERVE-IDENTITIES-P retains existing Ebox node and region identities.
SIGNALS, when non-nil, supplies the mounted surface's host context."
(if (stringp source)
(let ((object
(tp-object-ensure context nil ebox-surface--root-key
@ -579,52 +1023,63 @@ PRESERVE-IDENTITIES-P retains existing Ebox node and region identities."
:text source :capability 'content)))
(let* ((root (ebox-surface--candidate-root
source previous-state preserve-identities-p))
(axes (and signals (ebox-surface--context-axes root)))
(viewport-width
(and signals
(funcall
(if (car axes) #'tp-signal-read #'tp-signal-peek)
(ebox-surface--signals-viewport-width signals))))
(viewport-height
(and signals
(funcall
(if (cdr axes) #'tp-signal-read #'tp-signal-peek)
(ebox-surface--signals-viewport-height signals))))
(display-signature
(and signals
(tp-signal-read
(ebox-surface--signals-display signals))))
(scroll-offsets
(and signals
(tp-signal-peek
(ebox-surface--signals-scroll signals))))
(state
(ebox-surface--apply-state-overrides
(ebox--new-buffer-render-state root)
(copy-sequence state-overrides)))
(surface-root
(tp-object-ensure context nil ebox-surface--root-key 'ebox/surface))
(node-root
(tp-object-ensure context surface-root
ebox-surface--nodes-key 'ebox/nodes))
(objects-by-node (make-hash-table :test 'eq)))
(tp-object-retain context node-root)
(ebox-surface--ensure-node-tree context node-root root objects-by-node)
(plist-put state :region-box-table (make-hash-table :test 'equal))
(let ((rendered (ebox-surface--render-candidate state)))
(setq state (ebox-surface--finish-runtime-state state))
(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))))))
(copy-sequence state-overrides))))
(when signals
(plist-put state :viewport-width viewport-width)
(plist-put state :viewport-height viewport-height)
(plist-put state :display-signature display-signature)
(plist-put state :viewport-dependent-node-id-axes axes)
(plist-put state :viewport-dependent-node-ids
(delete-dups
(copy-sequence (append (car axes) (cdr axes)))))
(plist-put state :viewport-dependent-node-ids-ready t)
(ebox-surface--apply-scroll-offsets root scroll-offsets))
(let ((projection (ebox-surface--projection-start context root)))
(plist-put state :region-box-table (make-hash-table :test 'equal))
(let ((rendered (ebox-surface--render-candidate state)))
(when (and signals
(hash-table-p (plist-get state :scroll-state-table))
(> (hash-table-count
(plist-get state :scroll-state-table))
0))
(tp-signal-read (ebox-surface--signals-scroll signals)))
(setq state (ebox-surface--finish-runtime-state state))
(ebox-surface--projection-result
context projection state rendered))))))
(defun ebox-surface-producer
(source &optional previous-state preserve-identities-p state-overrides)
(source &optional previous-state preserve-identities-p state-overrides
signals)
"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."
STATE-OVERRIDES augments the candidate Ebox runtime state. SIGNALS carries
the mounted surface context; one-shot string materialization omits it."
(lambda (context)
(ebox-surface--project
context source previous-state preserve-identities-p state-overrides)))
context source previous-state preserve-identities-p state-overrides
signals)))
(provide 'ebox-surface)

5146
ebox.el

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,6 @@
(setq ebox--runtime-node-id-counter 0)
(setq ebox--render-region-id nil)
(dolist (table (list ebox--region-box-table
ebox--box-extents
ebox--buffer-render-state-table
ebox--scroll-global-state))
(clrhash table)))
@ -296,11 +295,14 @@
other-node))
(let ((calls nil))
(cl-letf (((symbol-function 'ebox-region-update)
(lambda (region-id &rest _props)
(let ((buffer ebox--region-update-buffer-hint))
(push (cons region-id buffer) calls)
(lambda (region-handle &rest _props)
(should (ebox-region-handle-p region-handle))
(let ((buffer
(car (ebox-selector--region-target
region-handle))))
(push (cons region-handle buffer) calls)
(list :buffer buffer
:region-id region-id
:region-handle region-handle
:strategy 'stub-direct)))))
(ebox-selector-update-buffer
other-buffer "#other-card" :border-color "#0f766e")
@ -330,11 +332,15 @@
node))
(let* ((match (car (ebox-selector-query-buffer buffer "#card")))
(region-id (plist-get match :region-id))
(region-handle (plist-get match :region-handle))
(poisoned-entry (list :poisoned-region region-id)))
(puthash region-id poisoned-entry ebox--region-box-table)
(cl-letf (((symbol-function 'ebox-region-update)
(lambda (called-region-id &rest _props)
(should (eq called-region-id region-id))
(lambda (called-handle &rest _props)
(should (ebox-region-handle-p called-handle))
(should (equal
(ebox-selector--region-target called-handle)
(ebox-selector--region-target region-handle)))
(should (eq (gethash region-id ebox--region-box-table)
poisoned-entry))
(list :strategy 'stub-direct))))
@ -363,8 +369,9 @@
:patch-ops '(owner-rerender)
:batched-count 2)))
(cl-letf (((symbol-function 'ebox-region-update)
(lambda (region-id &rest _props)
(push (list :region-id region-id
(lambda (region-handle &rest _props)
(should (ebox-region-handle-p region-handle))
(push (list :region-handle region-handle
:strategy 'wrong-direct)
direct-reports)))
((symbol-function 'ebox-incremental-flush)
@ -380,5 +387,33 @@
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-selector-multi-match-publishes-one-scoped-generation ()
"Multi-match updates should publish once through a scoped TP transaction."
(ebox-selector-test--reset-runtime-state)
(let* ((node (ebox-build
'(column
(box :class card :content "One")
(box :class card :content "Two"))))
buffer)
(unwind-protect
(progn
(setq buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*")
node))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(result
(ebox-selector-update-buffer
buffer ".card" :border-color "#ef4444"))
(report (car (plist-get result :reports))))
(should (= (tp-surface-revision surface) (1+ revision)))
(should-not (plist-get (tp-surface-report surface) :full-root))
(should (= (plist-get report :flush-count) 1))
(should (= (plist-get report :batched-count) 2))
(should (equal report (ebox-buffer-update-report buffer)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(provide 'ebox-selector-tests)
;;; ebox-selector-tests.el ends here

View File

@ -17,8 +17,7 @@
(setq ebox--region-id-counter 0
ebox--runtime-node-id-counter 0)
(dolist (table (list ebox--region-box-table
ebox--scroll-global-state
ebox--rendered-root-metadata-table))
ebox--scroll-global-state))
(clrhash table)))
(defun ebox-surface-test--interactive-content ()
@ -334,6 +333,179 @@
(kill-buffer buffer)))
(should-not (tp-surface-live-p surface))))
(ert-deftest ebox-viewport-rerender-publishes-only-through-tp ()
"A mounted viewport update should advance its TP surface."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-viewport*")))
(unwind-protect
(let ((ebox-viewport-width 120)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(ebox-render-to-buffer
buffer
(ebox-create :content "Viewport" :width '(viewport)))
(let* ((surface
(with-current-buffer buffer ebox-surface--buffer-surface))
(signals
(with-current-buffer buffer
ebox-surface--context-signals))
(revision (tp-surface-revision surface)))
(should (= (tp-signal-peek
(ebox-surface--signals-viewport-width signals))
120))
(ebox-rerender-buffer-with-context buffer 180 4)
(should (= (tp-surface-revision surface) (1+ revision)))
(should (= (tp-signal-peek
(ebox-surface--signals-viewport-width signals))
180))
(should (eq (ebox--buffer-render-state buffer)
(tp-surface-client-state surface)))
(should (= (with-current-buffer buffer
(ebox--string-pixel-width
(buffer-substring (line-beginning-position)
(line-end-position))))
180))
(let ((report (ebox-buffer-update-report buffer)))
(should (eq (plist-get report :constraint-source) 'viewport))
(should (plist-get report :runtime-published)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-surface-context-signals-track-exact-layout-dependencies ()
"A mounted producer should subscribe only to context it can consume."
(ebox-surface-test--reset-render-state)
(let ((responsive (generate-new-buffer " *ebox-responsive-signals*"))
(static (generate-new-buffer " *ebox-static-signals*"))
responsive-signals static-signals)
(unwind-protect
(let ((ebox-viewport-width 160)
(ebox-viewport-height 2))
(ebox-render-to-buffer
responsive
(ebox-create :content "zero\none\ntwo\nthree"
:width '(viewport)
:height '(viewport-height)
:overflow 'scroll))
(setq responsive-signals
(with-current-buffer responsive
ebox-surface--context-signals))
(dolist (signal
(list
(ebox-surface--signals-viewport-width
responsive-signals)
(ebox-surface--signals-viewport-height
responsive-signals)
(ebox-surface--signals-display responsive-signals)
(ebox-surface--signals-scroll responsive-signals)))
(should (tp-signal-live-p signal))
(should (= (tp-signal-subscriber-count signal) 1)))
(ebox-render-to-buffer
static
(ebox-create :content "Static" :width '(100)))
(setq static-signals
(with-current-buffer static ebox-surface--context-signals))
(should (= (tp-signal-subscriber-count
(ebox-surface--signals-viewport-width static-signals))
0))
(should (= (tp-signal-subscriber-count
(ebox-surface--signals-viewport-height static-signals))
0))
(should (= (tp-signal-subscriber-count
(ebox-surface--signals-display static-signals))
1))
(should (= (tp-signal-subscriber-count
(ebox-surface--signals-scroll static-signals))
0)))
(dolist (buffer (list responsive static))
(when (buffer-live-p buffer)
(kill-buffer buffer))))
(dolist (signals (list responsive-signals static-signals))
(when signals
(dolist (signal
(list (ebox-surface--signals-viewport-width signals)
(ebox-surface--signals-viewport-height signals)
(ebox-surface--signals-display signals)
(ebox-surface--signals-scroll signals)))
(should-not (tp-signal-live-p signal)))))))
(ert-deftest ebox-static-viewport-change-updates-context-without-rendering ()
"Unused viewport state should change without publishing the surface."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-static-viewport*")))
(unwind-protect
(let ((ebox-viewport-width 120)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(ebox-render-to-buffer
buffer
(ebox-create :content "Static" :width '(100)))
(let* ((surface
(with-current-buffer buffer ebox-surface--buffer-surface))
(signals
(with-current-buffer buffer
ebox-surface--context-signals))
(revision (tp-surface-revision surface))
(renders 0)
(original
(symbol-function 'ebox-surface--render-candidate)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (state)
(cl-incf renders)
(funcall original state))))
(ebox-rerender-buffer-with-context buffer 180 4))
(should (= renders 0))
(should (= (tp-surface-revision surface) revision))
(should (= (plist-get (tp-surface-client-state surface)
:viewport-width)
180))
(should (= (tp-signal-peek
(ebox-surface--signals-viewport-width signals))
180))
(should (eq (plist-get (ebox-buffer-update-report buffer)
:strategy)
'no-op))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-theme-change-rerenders-with-an-unchanged-viewport ()
"A changed display signature should invalidate a fixed-width surface."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-theme-context*")))
(unwind-protect
(let ((ebox-viewport-width 120)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(ebox-render-to-buffer
buffer
(ebox-create :content "Theme" :width '(100)))
(let* ((surface
(with-current-buffer buffer ebox-surface--buffer-surface))
(signals
(with-current-buffer buffer
ebox-surface--context-signals))
(revision (tp-surface-revision surface))
(next-signature '(ebox-test-theme dark)))
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda () next-signature)))
(ebox-rerender-buffer-with-context buffer 120 4))
(should (= (tp-surface-revision surface) (1+ revision)))
(should (equal
(tp-signal-peek
(ebox-surface--signals-display signals))
next-signature))
(should (equal (plist-get (tp-surface-client-state surface)
:display-signature)
next-signature))
(let ((report (ebox-buffer-update-report buffer)))
(should (plist-get report :host-context-changed))
(should (memq :display-signature
(plist-get report :dirty-keys))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(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)
@ -364,7 +536,7 @@
(kill-buffer buffer))))))
(ert-deftest ebox-commit-publishes-through-the-mounted-tp-surface ()
"Declarative commits should bypass every legacy Ebox buffer publisher."
"Declarative commits should publish through the mounted TP surface."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-commit*")))
(unwind-protect
@ -376,14 +548,11 @@
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)))))
(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)))
@ -423,6 +592,48 @@
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-context-signal-rolls-back-with-failed-publication ()
"A failed Ebox publication should restore its TP context source."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-context-rollback*")))
(unwind-protect
(let ((ebox-viewport-width 120)
(ebox-viewport-height 4))
(ebox-render-to-buffer
buffer
(ebox-create :key 'root :content "Stable"
:width '(viewport)))
(let* ((surface
(with-current-buffer buffer ebox-surface--buffer-surface))
(signals
(with-current-buffer buffer
ebox-surface--context-signals))
(width-signal
(ebox-surface--signals-viewport-width signals))
(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-surface-mount-buffer
buffer
(ebox-create :key 'root :content "Rejected"
:width '(viewport))
(ebox--update-report nil 'root-rerender)
(lambda (_report) (error "Reject context publication"))
nil
'(:viewport-width 180 :viewport-height 4)))
(should (= (tp-signal-peek width-signal) 120))
(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)
@ -496,6 +707,55 @@
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-region-handle-update-uses-scoped-tp-publication ()
"A handle update should publish a scoped TP plan."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-scoped-handle*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-column
(ebox-create :id "left" :content "Left" :width '(100))
(ebox-create :id "right" :content "Right" :width '(100))))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(handle (ebox-region-resolve buffer "left"))
report)
(setq report
(ebox-region-update handle :content "Changed"))
(should (= (tp-surface-revision surface) (1+ revision)))
(should-not (plist-get (tp-surface-report surface) :full-root))
(should (> (plist-get (tp-surface-report surface) :scope-count) 0))
(should (eq (plist-get report :tp-render-scope) 'objects))
(should (string-match-p
"Changed"
(with-current-buffer buffer (buffer-string))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-region-update-rejects-process-global-region-ids ()
"Direct updates should require a surface-scoped region handle."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-handle-only-update*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-create :id "target" :content "Before" :width '(100)))
(let* ((match (car (ebox-selector-query-buffer buffer "#target")))
(region-id (plist-get match :region-id)))
(should-error (ebox-region-update region-id :content "Wrong")
:type 'wrong-type-argument)
(ebox-region-update (plist-get match :region-handle)
:content "After")
(should (string-match-p
"After"
(with-current-buffer buffer (buffer-string))))))
(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)
@ -535,6 +795,91 @@
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-update-publishes-only-through-tp ()
"A mounted scroll update should advance one TP surface revision."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-surface-scroll*")))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(ebox-render-to-buffer
buffer
(ebox-create :id "scroll" :content "zero\none\ntwo\nthree"
:width '(120) :height 2 :overflow 'scroll))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(signals
(with-current-buffer buffer
ebox-surface--context-signals))
(revision (tp-surface-revision surface))
(region-id
(plist-get
(car (ebox-selector-query-buffer buffer "#scroll"))
:region-id)))
(should (= (ebox--scroll-region-by region-id 1 1) 1))
(should (= (tp-surface-revision surface) (1+ revision)))
(should (= (alist-get
region-id
(tp-signal-peek
(ebox-surface--signals-scroll signals)))
1))
(let* ((state (tp-surface-client-state surface))
(root (plist-get state :root-node))
(text (with-current-buffer buffer
(buffer-substring-no-properties
(point-min) (point-max)))))
(should (= (ebox-get (ebox--root-region-box root region-id)
:scroll-offset)
1))
(should (= (plist-get (ebox-scroll-state region-id)
:scroll-offset)
1))
(should (string-match-p "one" text))
(should (string-match-p "two" text))
(should-not (string-match-p "zero" text)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-update-rejects-a-runtime-replaced-by-its-hook ()
"A stale scroll candidate must not overwrite a hook publication."
(ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-scroll-hook-race*")))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(ebox-render-to-buffer
buffer
(ebox-create :id "scroll" :content "zero\none\ntwo\nthree"
:width '(120) :height 2 :overflow 'scroll))
(let* ((surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(handle (ebox-region-resolve buffer "scroll"))
(region-id
(plist-get
(car (ebox-selector-query-buffer buffer "#scroll"))
:region-id))
(ebox-incremental-before-runtime-mutation-hook
(list
(lambda (target kind)
(when (and (eq target buffer) (eq kind 'scroll))
(let ((ebox-incremental--runtime-mutation-hooks-inhibited-p
t))
(ebox-region-update handle :color "#2563EB")))))))
(should-error (ebox--scroll-region-by region-id 1 1))
(should (= (tp-surface-revision surface) (1+ revision)))
(should (= (plist-get (ebox-scroll-state region-id) :scroll-offset)
0))
(should (equal
(ebox-get
(ebox--root-region-box
(plist-get (tp-surface-client-state surface) :root-node)
region-id)
:color)
"#2563EB"))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(provide 'ebox-surface-tests)
;;; ebox-surface-tests.el ends here