Keep layer and scroll updates local with isolated cache publication
Some checks are pending
CI / test (29.1) (push) Waiting to run
CI / test (30.2) (push) Waiting to run
CI / native-build (macos-latest) (push) Waiting to run
CI / native-build (ubuntu-latest) (push) Waiting to run
CI / native-build (windows-latest) (push) Waiting to run
CI / native-msrv (macos-latest) (push) Waiting to run
CI / native-msrv (ubuntu-latest) (push) Waiting to run
CI / native-msrv (windows-latest) (push) Waiting to run

Batch region callbacks and preserve native repeated-click behavior. Reuse proven layer slots, root scroll content and nested viewport allocations while retaining conservative fallbacks.

Isolate cold prefix producers and caches before publication, retain current-owner continuations, and project separator ownership consistently. Add lifecycle, rollback, layout and performance regression coverage with bilingual contracts.
This commit is contained in:
Kinneyzhang 2026-09-10 23:15:03 +08:00
parent 2a64573d42
commit efdc35f4af
27 changed files with 3385 additions and 374 deletions

View File

@ -25,7 +25,7 @@ all: check
.PHONY: layer-tests .PHONY: layer-tests
layer-tests: layer-tests:
$(EMACS_TEST) -l tests/ebox-composite-tests.el -l tests/ebox-layer-style-tests.el -l tests/ebox-layer-tests.el -l tests/ebox-layer-publication-tests.el -f ert-run-tests-batch-and-exit $(EMACS_TEST) -l tests/ebox-composite-tests.el -l tests/ebox-layer-style-tests.el -l tests/ebox-layer-tests.el -l tests/ebox-layer-publication-tests.el -l tests/ebox-layer-scroll-tests.el -f ert-run-tests-batch-and-exit
test: layer-tests test: layer-tests
@ -34,6 +34,24 @@ interaction-tests:
test: interaction-tests test: interaction-tests
.PHONY: nested-scroll-tests
nested-scroll-tests:
$(EMACS_TEST) -l tests/ebox-nested-scroll-tests.el -f ert-run-tests-batch-and-exit
test: nested-scroll-tests
.PHONY: scroll-slice-tests
scroll-slice-tests:
$(EMACS_TEST) -l tests/ebox-scroll-slice-tests.el -f ert-run-tests-batch-and-exit
test: scroll-slice-tests
.PHONY: scroll-cold-tests
scroll-cold-tests:
$(EMACS_TEST) -l tests/ebox-scroll-cold-tests.el -f ert-run-tests-batch-and-exit
test: scroll-cold-tests
display-tests: display-tests:
$(EMACS_TEST) -l tests/ebox-display-tests.el -f ert-run-tests-batch-and-exit $(EMACS_TEST) -l tests/ebox-display-tests.el -f ert-run-tests-batch-and-exit

View File

@ -47,17 +47,33 @@ If any proof condition fails, the update uses the ordinary safe projection path.
## Retained scroll window ## Retained scroll window
For a chrome-free root scroll owner whose cached rendered lines cover the Cached scroll intents reuse the published cascade, viewport axes and unchanged
current visible window (the lazy prefix may remain incomplete), runtime indexes when the visible window and display context are proven stable.
the scroll patch joins the staged visible window directly instead of entering Chrome-free roots join cached rendered lines directly; layered or chrome-bearing
the full candidate layout. It reuses the published cascade, viewport axes, roots may reuse their complete composed content and render only the wrapper.
object maps, and descendant `region-box-table`; it only advances the scroll Pure root offset changes preserve already composed portal and anchor content.
offset and installs the root `ebox-scroll-window` property. The proof is Style, placement, content and display-context changes must invalidate that proof.
conservative: a cache miss, chrome, active style dependency, topology change,
or incomplete window falls back to the ordinary retained path. Scroll A nested owner can replace its viewport within a proven unchanged allocation,
publication still goes through TP and the Ebox participant, so a failure after without rendering unrelated siblings. With one unchanged root scroll ancestor,
TP client-state publication restores text, offsets, indexes, objects, mounts, the update must also patch that ancestor's retained content, ownership membership
and report together. and line indexes, and rebind its producer to the candidate tree. Cached-content
coordinates and mounted-buffer coordinates require separate proofs. Unsupported
ancestor chains, partial visibility and failed footprint or wrapper proofs retain
the ordinary fallback.
Classify cache hits before staging. A true prefix miss owns a copied target
subtree and ancestor path, private indexes and registries, copied cache lines and
freshly rebound producers. A producer must not mutate a published continuation
before TP commits. This isolation can rebuild the target prefix on a miss;
cached-hit timings do not describe that cost.
Scroll publication still goes through TP and the Ebox participant. Rejection
preserves the old text, properties, offsets, raw and rendered caches, source
nodes, indexes, objects, mounts and report. The focused regression targets are
`make layer-tests`, `make nested-scroll-tests`, `make scroll-cold-tests`
and `make scroll-slice-tests`; the playground scroll evaluator checks fresh-render
parity and distinguishes cached motion from actual prefix extension.
## Identity and coordinates ## Identity and coordinates

View File

@ -47,14 +47,28 @@ span、owner-scoped、formatting-context 或 mixed 局部输出被拒绝时pr
## 保留式 scroll window ## 保留式 scroll window
对于 chrome-free、缓存 rendered lines 已覆盖当前可见窗口的 root scroll owner 缓存滚动在可见窗口和 display context 的稳定性得到证明后,复用已发布的 cascade、
lazy prefix 可以仍未完整 materializescroll patch 会 viewport axes 与未变化的 runtime index。Chrome-free root 直接拼接 rendered lines
直接从 staged visible window 拼接输出,不再进入完整 candidate layout。它复用已 layered 或带 chrome 的 root 可以复用完整合成内容,只渲染 wrapper。纯 root offset
发布的 cascade、viewport axes、object maps 和 descendant `region-box-table`,只推进 变化保留已经合成的 portal 和 anchor 内容style、placement、content 或 display
scroll offset 并安装 root `ebox-scroll-window` 属性。证明保持保守cache miss、 context 变化必须使该证明失效。
chrome、active style dependency、topology 变化或窗口不完整时,回退到普通 retained
路径。Scroll publication 仍经过 TP 与 Ebox participant即使 TP client-state 发布 Nested owner 在 allocation 不变且通过证明时,可以只替换自己的 viewport避免渲染
之后失败也会一起恢复文本、offset、index、object、mount 和 report。 无关兄弟节点。若存在一个 offset 不变的 root scroll ancestor还必须更新该祖先的
保留内容、ownership membership 和 line index并将 producer 重新绑定到 candidate
树。缓存内容坐标与 mounted buffer 坐标分别验证。不支持的祖先链、部分可见的 owner
以及 footprint 或 wrapper 证明失败的情况,保留普通 fallback。
必须在 staging 前区分 cache hit 与真实 prefix miss。Miss 使用复制的目标子树和祖先
路径、私有 index 与 registry、复制的缓存行以及重新绑定的 producerproducer
不得在 TP commit 前修改已发布的 continuation。该隔离可能在 miss 时重建目标 prefix
因此 cached-hit 耗时不能代表 miss 的代价。
Scroll publication 仍经过 TP 与 Ebox participant。发布失败时旧文本、属性、offset、
raw/rendered cache、source node、index、object、mount 和 report 均保持不变。聚焦回归
入口为 `make layer-tests`、`make nested-scroll-tests`、`make scroll-cold-tests`
`make scroll-slice-tests`playground scroll evaluator 验证 fresh-render parity
并区分缓存滚动与实际 prefix 扩展。
## Identity 与坐标 ## Identity 与坐标

View File

@ -218,10 +218,14 @@ no command binding by itself.
``` ```
`:activate` accepts a zero-argument function and binds it to `RET`, `[return]`, `:activate` accepts a zero-argument function and binds it to `RET`, `[return]`,
`SPC`, and `[mouse-1]`. `:bindings` accepts an alist from key-description strings `SPC`, and `[mouse-1]`. Double and triple primary-button releases also activate
once per click. Primary-button press and drag events, including their double
and triple variants, do nothing by default, avoiding native text selection.
`:bindings` accepts an alist from key-description strings
or event vectors to zero-argument functions. Both options are optional; `nil` or event vectors to zero-argument functions. Both options are optional; `nil`
omits their bindings. Duplicate keys and overlapping key prefixes are errors, omits their bindings. Duplicate keys and overlapping key prefixes are errors,
including overlaps with the activation keys. The helper returns a native Emacs including overlaps with the four primary activation keys. Explicit bindings
can override the repeated-click, press and drag defaults. The helper returns a native Emacs
keymap; callbacks remain literal until their command is invoked. For mouse keymap; callbacks remain literal until their command is invoked. For mouse
events, the helper obtains the event window and runs the callback in its buffer. events, the helper obtains the event window and runs the callback in its buffer.
It does not select that window or move point. Keyboard callbacks run in the It does not select that window or move point. Keyboard callbacks run in the
@ -397,6 +401,7 @@ They do not publish by themselves.
| `ebox-region-ids` | Return current low-level region ids in document order. | | `ebox-region-ids` | Return current low-level region ids in document order. |
| `ebox-region-resolve` | Resolve one logical `:id` in a mounted buffer to an opaque surface-scoped handle. | | `ebox-region-resolve` | Resolve one logical `:id` in a mounted buffer to an opaque surface-scoped handle. |
| `ebox-region-update` | Apply supported mutable style, scroll, or native node capability properties through one retained transaction. Accept an opaque region handle or a semantic `:id` in the current mounted buffer. | | `ebox-region-update` | Apply supported mutable style, scroll, or native node capability properties through one retained transaction. Accept an opaque region handle or a semantic `:id` in the current mounted buffer. |
| `ebox-call-with-update-batch` | `(CALLBACK &optional BUFFER)` runs a zero-argument callback in the target buffer and groups its queued region updates; return the callback's value. |
| `ebox-child-range` | Framework integration only: build a nonvisual, addressable child Range descriptor while assembling a typed canonical input. | | `ebox-child-range` | Framework integration only: build a nonvisual, addressable child Range descriptor while assembling a typed canonical input. |
| `ebox-candidate-begin` | Capture the exact current mounted generation in a one-shot candidate. | | `ebox-candidate-begin` | Capture the exact current mounted generation in a one-shot candidate. |
| `ebox-candidate-replace` | Replace one candidate node address from a single-root `CanonicalEboxInput`. | | `ebox-candidate-replace` | Replace one candidate node address from a single-root `CanonicalEboxInput`. |
@ -426,6 +431,27 @@ blocks an enclosing value. Omitted properties are unchanged. Replace the
later caller-side mutations do not update a mounted surface. Clearing it removes that later caller-side mutations do not update a mounted surface. Clearing it removes that
node's keymap; ordinary Emacs buffer and global bindings still apply. node's keymap; ordinary Emacs buffer and global bindings still apply.
### Grouping region updates
`ebox-call-with-update-batch` defaults `BUFFER` to the current buffer and runs
`CALLBACK` there. If it starts the batch, it flushes once after a normal return
when changes are pending. An empty or unchanged callback publishes nothing and
preserves the last successful update report. The helper returns the callback's
value unchanged after a successful flush.
If a batch is already active in that buffer, the helper joins it without
flushing; the outer owner decides when to publish. On an error, quit, or other
nonlocal exit, the helper discards its own queued changes and restores the
outer batch's pending state as it was on entry. A failed flush of a batch owned
by this helper discards its unpublished changes; the existing TP transaction
restores the previously committed buffer, runtime, revision, and report.
The rollback scope is the queued `ebox-region-update` work in `BUFFER`.
Assignments to application variables, file writes, and other business side
effects in the callback are not reverted. Keep explicit
`ebox-incremental-begin-batch` / `ebox-incremental-flush` lifecycle calls outside
the callback; those lower-level calls retain their own retry semantics.
The author DSL has no Range form. Ordinary authors compose direct Text/Box The author DSL has no Range form. Ordinary authors compose direct Text/Box
children and never call `ebox-child-range`; Range addresses exist only for children and never call `ebox-child-range`; Range addresses exist only for
frameworks that already own semantic child ranges and candidate updates. frameworks that already own semantic child ranges and candidate updates.
@ -493,6 +519,7 @@ names every entry:
`ebox-candidate-replace-host-ref`, `ebox-candidate-patch-host-paint`, `ebox-candidate-replace-host-ref`, `ebox-candidate-patch-host-paint`,
`ebox-child-range`, `ebox-child-range`,
`ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update`, `ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update`,
`ebox-call-with-update-batch`,
`ebox-host-ref-bounds`, `ebox-host-ref-position`. `ebox-host-ref-bounds`, `ebox-host-ref-position`.
- Selectors: `ebox-selector-parse`, `ebox-selector-match-node-p`, - Selectors: `ebox-selector-parse`, `ebox-selector-match-node-p`,
`ebox-selector-query-all`, `ebox-selector-query-buffer`, `ebox-selector-query-all`, `ebox-selector-query-buffer`,

View File

@ -185,9 +185,12 @@ Emacs 设置及窗口系统;单独设置指针形状不会绑定命令。
``` ```
`:activate` 接收零参数函数,将其绑定到 `RET`、`[return]`、`SPC` 和 `[mouse-1]` `:activate` 接收零参数函数,将其绑定到 `RET`、`[return]`、`SPC` 和 `[mouse-1]`
主按钮双击、三击的每次释放也各激活一次。主按钮按下和拖动事件,包括其双击、三击
变体,默认不执行操作,避免触发原生文本选择。
`:bindings` 接收 alistkey 是按键描述字符串或事件向量value 是零参数函数。 `:bindings` 接收 alistkey 是按键描述字符串或事件向量value 是零参数函数。
两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与激活键 两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与上述四个
重叠的绑定。辅助函数返回原生 Emacs keymap回调保留为函数直到对应命令触发时才调用。 基础激活键重叠的绑定。显式绑定可以覆盖连击、按下和拖动事件的默认行为。
辅助函数返回原生 Emacs keymap回调保留为函数直到对应命令触发时才调用。
鼠标事件会自动从事件取得窗口,并在其 buffer 中运行回调,不选择窗口或移动 point。 鼠标事件会自动从事件取得窗口,并在其 buffer 中运行回调,不选择窗口或移动 point。
键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。 键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。
@ -343,6 +346,7 @@ observer 为 nil 时Ebox 不创建 observation context不取 timestamp/GC
| `ebox-region-ids` | 按文档顺序返回当前底层 region id。 | | `ebox-region-ids` | 按文档顺序返回当前底层 region id。 |
| `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 | | `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 |
| `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式、滚动或原生节点能力 property接受不透明 region handle或当前已挂载 buffer 中的语义 `:id`。 | | `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式、滚动或原生节点能力 property接受不透明 region handle或当前已挂载 buffer 中的语义 `:id`。 |
| `ebox-call-with-update-batch` | `(CALLBACK &optional BUFFER)` 在目标 buffer 中运行零参数回调,合并其排队的 region 更新,并返回回调的返回值。 |
| `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 | | `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 |
| `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 | | `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 |
| `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 | | `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 |
@ -369,6 +373,24 @@ candidate 被 commit 封存后不能复用。
之后直接修改原 map 不会更新已挂载的 surface应显式替换 `:keymap` 来发布新绑定。 之后直接修改原 map 不会更新已挂载的 surface应显式替换 `:keymap` 来发布新绑定。
清除该属性只移除节点 keymap普通 Emacs buffer 和全局绑定仍然适用。 清除该属性只移除节点 keymap普通 Emacs buffer 和全局绑定仍然适用。
### 合并 region 更新
`ebox-call-with-update-batch``BUFFER` 默认是当前 buffer`CALLBACK` 在该
buffer 中执行。如果 helper 自己创建 batch回调正常返回且存在待处理变化时只
flush 一次。空回调或未产生变化的回调不会发布,也不会替换最近一次成功更新报告。
flush 成功后helper 原样返回回调的返回值。
目标 buffer 已有活动 batch 时helper 加入该 batch 而不 flush由外层拥有者决定
何时发布。回调发生 error、quit 或其他非局部退出时helper 丢弃本次调用排队的
变化,并将外层 batch 的待处理状态恢复到进入回调前的检查点。helper 自己拥有的
batch 若 flush 失败,其未发布变化会被丢弃;已有 TP transaction 恢复此前已提交
的 buffer、runtime、revision 和报告。
回滚范围仅限于 `BUFFER` 中排队的 `ebox-region-update` 工作。回调对应用变量的
赋值、文件写入及其他业务副作用不会被撤销。显式
`ebox-incremental-begin-batch` / `ebox-incremental-flush` 生命周期调用应放在回调
外部;这些底层调用保留各自的重试语义。
author DSL 没有 Range form。普通 author 只组合直接嵌套的 Text/Box child不调用 author DSL 没有 Range form。普通 author 只组合直接嵌套的 Text/Box child不调用
`ebox-child-range`Range address 只服务已经拥有 semantic child range 与 candidate `ebox-child-range`Range address 只服务已经拥有 semantic child range 与 candidate
update 的框架。 update 的框架。
@ -429,6 +451,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它
`ebox-candidate-replace-host-ref`、`ebox-candidate-patch-host-paint`、 `ebox-candidate-replace-host-ref`、`ebox-candidate-patch-host-paint`、
`ebox-child-range` `ebox-child-range`
`ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update`、 `ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update`、
`ebox-call-with-update-batch`
`ebox-host-ref-bounds`、`ebox-host-ref-position` `ebox-host-ref-bounds`、`ebox-host-ref-position`
- Selector`ebox-selector-parse`、`ebox-selector-match-node-p`、 - Selector`ebox-selector-parse`、`ebox-selector-match-node-p`、
`ebox-selector-query-all`、`ebox-selector-query-buffer`、 `ebox-selector-query-all`、`ebox-selector-query-buffer`、

View File

@ -436,10 +436,14 @@ value; an omitted property leaves the enclosing surface coverage in place.
`:keymap` accepts a native Emacs keymap or `nil`. Prefer `ebox-keymap-create`: `:keymap` accepts a native Emacs keymap or `nil`. Prefer `ebox-keymap-create`:
its `:activate` callback takes no arguments and handles `RET`, `[return]`, `SPC`, its `:activate` callback takes no arguments and handles `RET`, `[return]`, `SPC`,
and `[mouse-1]`. Use `:bindings` for an alist of key-description strings or event and `[mouse-1]`. Rapid double and triple clicks each activate once per release;
primary-button press and drag events do nothing, preserving point and selection
instead of starting native word or line selection. Use `:bindings` for an alist
of key-description strings or event
vectors paired with zero-argument callbacks. The helper uses the event window's vectors paired with zero-argument callbacks. The helper uses the event window's
buffer for mouse callbacks, so a callback can directly update a semantic ID buffer for mouse callbacks, so a callback can directly update a semantic ID
with `(ebox-region-update "action" :help-echo "Activated")`. with `(ebox-region-update "action" :help-echo "Activated")`.
Explicit bindings can override the repeated-click, press and drag defaults.
Move point into the surface to use keyboard bindings. Optional commands Move point into the surface to use keyboard bindings. Optional commands
`ebox-next-interaction` and `ebox-previous-interaction` move between declaring `ebox-next-interaction` and `ebox-previous-interaction` move between declaring
@ -551,6 +555,26 @@ buffer. `ebox-selector-update-buffer` applies one style update to all editable
matches. Numeric region ids are diagnostic render metadata, not stable update matches. Numeric region ids are diagnostic render metadata, not stable update
handles. handles.
For one action that makes several region updates, group them:
```elisp
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update "status" :help-echo "Ready")
(ebox-region-update "status" :color "#166534")
'ready)
(get-buffer "*Ebox Guide*"))
```
The callback runs in the supplied buffer, or the current buffer when omitted,
and the helper returns `ready` here. It flushes its own nonempty batch once;
inside an existing batch it leaves the flush to the outer owner. A nonlocal
exit restores the pending state from before this call, and a failed owned
flush preserves the last committed surface. This does not undo arbitrary
business side effects in the callback. See the [batch API
contract](ebox-api-reference.en.md#grouping-region-updates) for nesting, no-op,
and explicit lifecycle behavior.
## 7. Resize and scroll ## 7. Resize and scroll
When content exceeds a finite height, `:overflow scroll` creates an internal When content exceeds a finite height, `:overflow scroll` creates an internal

View File

@ -371,9 +371,12 @@ margin 及结构性换行符;悬停遵循上面的规则。嵌套的 Text 或
`:keymap` 接受原生 Emacs keymap 或 `nil`。推荐使用 `ebox-keymap-create` `:keymap` 接受原生 Emacs keymap 或 `nil`。推荐使用 `ebox-keymap-create`
`:activate` 的回调不带参数,处理 `RET`、`[return]`、`SPC` 和 `[mouse-1]` `:activate` 的回调不带参数,处理 `RET`、`[return]`、`SPC` 和 `[mouse-1]`
快速双击、三击的每次释放也各激活一次;主按钮按下和拖动默认不执行操作,保留 point
和选区,避免触发原生的选词或整行选择。
`:bindings` 接受 alist按键描述字符串或事件向量对应零参数回调。 `:bindings` 接受 alist按键描述字符串或事件向量对应零参数回调。
辅助函数让鼠标回调在事件窗口的 buffer 中运行,因此回调可以直接按语义 ID 更新: 辅助函数让鼠标回调在事件窗口的 buffer 中运行,因此回调可以直接按语义 ID 更新:
`(ebox-region-update "action" :help-echo "Activated")` `(ebox-region-update "action" :help-echo "Activated")`
显式绑定可以覆盖连击、按下和拖动事件的默认行为。
使用键盘绑定时,先把 point 移到交互范围内。可选命令 `ebox-next-interaction` 使用键盘绑定时,先把 point 移到交互范围内。可选命令 `ebox-next-interaction`
`ebox-previous-interaction` 在当前渲染了可用 keymap 的声明节点之间移动。每个 owner `ebox-previous-interaction` 在当前渲染了可用 keymap 的声明节点之间移动。每个 owner
@ -468,6 +471,23 @@ Ebox 对 keymap 创建快照;发布新绑定时应替换 `:keymap`,而不是
`ebox-selector-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id `ebox-selector-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id
只是诊断用渲染 metadata不是稳定更新 handle。 只是诊断用渲染 metadata不是稳定更新 handle。
一个操作需要多次更新 region 时,可以合并这些更新:
```elisp
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update "status" :help-echo "Ready")
(ebox-region-update "status" :color "#166534")
'ready)
(get-buffer "*Ebox Guide*"))
```
回调在指定 buffer 中运行,省略 buffer 时使用当前 buffer本例的 helper 返回
`ready`。helper 只 flush 一次自己拥有的非空 batch已有 batch 时,由外层拥有者
负责 flush。非局部退出会恢复到本次调用前的待处理状态自己拥有的 batch 若
flush 失败则保留上次已提交 surface。回调中的其他业务副作用不会被撤销。嵌套、
no-op 和显式生命周期行为见[批量更新契约](ebox-api-reference.zh.md#合并-region-更新)。
## 7. Resize 与滚动 ## 7. Resize 与滚动
内容超过有限高度时,`:overflow scroll` 会创建内部 scroll window。键盘滚动以 内容超过有限高度时,`:overflow scroll` 会创建内部 scroll window。键盘滚动以

View File

@ -35,7 +35,11 @@
Retain the source properties and nested display values by identity." Retain the source properties and nested display values by identity."
(let* ((result (substring line start end)) (let* ((result (substring line start end))
(display (get-text-property start 'display line)) (display (get-text-property start 'display line))
(properties (copy-sequence (cdr display)))) (properties (copy-sequence (cdr display)))
;; Match `ebox-pixel-space': retained integer allocations and
;; freshly resolved float allocations describe the same pixels.
(whole-pixels (truncate width))
(width (if (= width whole-pixels) whole-pixels width)))
(put-text-property (put-text-property
0 (length result) 'display 0 (length result) 'display
(cons 'space (plist-put properties :width (list width))) result) (cons 'space (plist-put properties :width (list width))) result)

View File

@ -104,6 +104,8 @@
(declare-function ebox-layer-host-p "ebox-layer" (node)) (declare-function ebox-layer-host-p "ebox-layer" (node))
(declare-function ebox-layer-subtree-p "ebox-layer" (node)) (declare-function ebox-layer-subtree-p "ebox-layer" (node))
(declare-function ebox-layer-portals-p "ebox-layer" (node)) (declare-function ebox-layer-portals-p "ebox-layer" (node))
(declare-function ebox-layer-visible-portals-p "ebox-layer" (node))
(declare-function ebox-layer-cached-root-scroll-p "ebox-layer" (root state))
(cl-defstruct (cl-defstruct
(ebox-incremental-context-port (ebox-incremental-context-port
@ -1070,8 +1072,8 @@ When FORCE is non-nil, cache NODE even when its output is viewport-dependent;
the complete viewport context remains part of the render signature. the complete viewport context remains part of the render signature.
CACHE-PROBE may supply a prior exact miss from an accelerator boundary." CACHE-PROBE may supply a prior exact miss from an accelerator boundary."
(if (and ebox-incremental--render-portals-p (if (and ebox-incremental--render-portals-p
(fboundp 'ebox-layer-portals-p) (fboundp 'ebox-layer-visible-portals-p)
(ebox-layer-portals-p node)) (ebox-layer-visible-portals-p node))
;; Cached strings do not replay the candidate-local portal collector. ;; Cached strings do not replay the candidate-local portal collector.
(ebox--render-node node) (ebox--render-node node)
(if-let* ((prepared (and force (if-let* ((prepared (and force
@ -1796,8 +1798,10 @@ separately for every role property."
(setcdr range (max (cdr range) end)) (setcdr range (max (cdr range) end))
(puthash line-number (cons start end) line-map)))) (puthash line-number (cons start end) line-map))))
(defun ebox--build-region-line-index () (defun ebox--build-region-line-index (&optional include-owner-stacks-p)
"Return a per-line index of ebox region ownership in the current buffer." "Return a per-line index of ebox region ownership in the current buffer.
INCLUDE-OWNER-STACKS-P includes retained wrapper ownership in an unmounted
scroll cache, whose nested owners have no TP role mounts yet."
(let ((region-lines (make-hash-table :test 'equal)) (let ((region-lines (make-hash-table :test 'equal))
(line-number 0) (line-number 0)
lines) lines)
@ -1810,7 +1814,9 @@ separately for every role property."
(while (< pos line-end) (while (< pos line-end)
(let* ((role-ids (ebox--pos-region-role-ids pos)) (let* ((role-ids (ebox--pos-region-role-ids pos))
(ids (delete-dups (ids (delete-dups
(mapcar #'cdr (copy-sequence role-ids)))) (append (mapcar #'cdr (copy-sequence role-ids))
(and include-owner-stacks-p
(copy-sequence (get-text-property pos 'ebox-content-owners))))))
(next (ebox--next-region-property-change (next (ebox--next-region-property-change
pos line-end))) pos line-end)))
(when ids (when ids
@ -3639,15 +3645,18 @@ running expensive span and snapshot checks."
(not (equal source-node-id node-id)) (not (equal source-node-id node-id))
(eq dirty-kind 'geometry) (eq dirty-kind 'geometry)
(memq :content (plist-get entry :changed-keys)) (memq :content (plist-get entry :changed-keys))
(cl-some (or (ebox-incremental--content-independent-allocation-p
(lambda (signature) (ebox--buffer-render-state buffer) node-id)
(let ((content (plist-get signature :content))) (cl-some
(and (stringp content) (string-match-p "\n" content)))) (lambda (signature)
(list (plist-get entry :old-signature) (let ((content (plist-get signature :content)))
(plist-get entry :new-signature))) (and (stringp content) (string-match-p "\n" content))))
(list (plist-get entry :old-signature)
(plist-get entry :new-signature))))
;; Canonical Text owns the content identity; its Box owns the ;; Canonical Text owns the content identity; its Box owns the
;; allocated slot. Ask the same slot proof about that owner ;; allocated slot. The same proof handles both one-line text
;; before promoting a text-length change to the parent layout. ;; and bounded multiline content; source identity alone must
;; not promote a local text change to the parent layout.
(ebox-incremental--content-slot-stable-p (ebox-incremental--content-slot-stable-p
buffer (plist-put (copy-sequence entry) :node-id node-id) buffer (plist-put (copy-sequence entry) :node-id node-id)
ebox-incremental--candidate-state-for-slot-proof)) ebox-incremental--candidate-state-for-slot-proof))
@ -8637,6 +8646,7 @@ the inner host's output."
(parents (plist-get candidate-state :parent-table)) (parents (plist-get candidate-state :parent-table))
(ebox-incremental--buffer-render-state-override (ebox-incremental--buffer-render-state-override
(cons buffer candidate-state)) (cons buffer candidate-state))
(ebox-incremental--candidate-state-for-slot-proof candidate-state)
affected operations) affected operations)
(dolist (dirty dirty-set) (dolist (dirty dirty-set)
(unless (eq (plist-get dirty :dirty-kind) 'metadata) (unless (eq (plist-get dirty :dirty-kind) 'metadata)
@ -8662,12 +8672,16 @@ the inner host's output."
:layer-recompose-p t)) :layer-recompose-p t))
(ebox-incremental--declarative-tentative-op buffer dirty)) (ebox-incremental--declarative-tentative-op buffer dirty))
operations)))) operations))))
;; A root portal can cover or anchor to nodes outside its local host. ;; A visible root portal can cover or anchor to nodes outside its host.
;; Their updates therefore belong to the root composition as well. ;; Test both snapshots so revealing and hiding keep root composition,
;; while hidden portals do not widen unrelated local updates.
(when (or affected (when (or affected
(and operations (plist-get old-state :root-portals-p))) (and operations (plist-get old-state :root-portals-p)))
(if (or (plist-get old-state :root-portals-p) (if (or (and (plist-get old-state :root-portals-p)
(ebox-layer-portals-p (plist-get candidate-state :root-node))) (ebox-layer-visible-portals-p
(plist-get old-state :root-node)))
(ebox-layer-visible-portals-p
(plist-get candidate-state :root-node)))
(list (ebox--patch-op (list (ebox--patch-op
'owner-rerender 'owner-rerender
(plist-get (plist-get candidate-state :root-node) :node-id) (plist-get (plist-get candidate-state :root-node) :node-id)
@ -8675,24 +8689,64 @@ the inner host's output."
:layer-recompose-p t)) :layer-recompose-p t))
(ebox-patch-plan-merge-ops parents (nreverse operations))))))) (ebox-patch-plan-merge-ops parents (nreverse operations)))))))
(defun ebox-incremental--content-independent-allocation-p (state owner-id)
"Return non-nil when STATE allocates OWNER-ID independently of its content.
Intrinsic widths must render naturally so the final footprint check can detect
growth. Automatic widths are retained only through normal flow/Column parents;
row, Flex and Grid allocation require their own formatting-context proofs."
(let ((nodes (plist-get state :node-table))
(parents (plist-get state :parent-table))
(id owner-id)
(valid (and (plist-get state :node-table)
(plist-get state :parent-table))))
(while (and valid id)
(let* ((node (ebox-runtime-index-get id nodes))
(parent-id (ebox-runtime-index-get id parents))
(parent (and parent-id (ebox-runtime-index-get parent-id nodes)))
(width (ebox-get node :width)))
(setq valid
(and node
(cl-every
(lambda (value)
(or (memq value '(nil auto stretch none))
(numberp value) (ebox-size-value-p value)))
(list width (ebox-get node :min-width)
(ebox-get node :max-width)))
(or (not (memq width '(nil auto stretch)))
(and parent
(eq (ebox-tree-display-outer node) 'block)
(memq (ebox-tree-display-inner parent)
'(flow column)))))
id parent-id)))
valid))
(defun ebox-incremental--layer-span-proof (defun ebox-incremental--layer-span-proof
(buffer old-state prepared candidate-state owner-plan path-copied-p) (buffer old-state prepared candidate-state owner-plan path-copied-p)
"Prove the stable outer allocation of one recomposed layer owner. "Prove the stable outer allocation of one recomposed layer owner.
Compare BUFFER's OLD-STATE with CANDIDATE-STATE using PREPARED dirty facts Compare BUFFER's OLD-STATE with CANDIDATE-STATE using PREPARED dirty facts
and OWNER-PLAN. PATH-COPIED-P requires private nodes inside that owner. and OWNER-PLAN. PATH-COPIED-P requires private nodes inside that owner.
Internal visibility and role topology may change. The surface validates the Internal visibility and role topology may change. The surface validates the
complete replacement footprint before publishing any resulting spans." complete replacement footprint before publishing any resulting spans. Freeze
the retained margin-box width only when the owner's layout style is unchanged."
(when (and (= (length owner-plan) 1) (when (and (= (length owner-plan) 1)
(plist-get (car owner-plan) :layer-recompose-p)) (plist-get (car owner-plan) :layer-recompose-p))
(let* ((owner-id (plist-get (car owner-plan) :owner-id)) (let* ((owner-id (plist-get (car owner-plan) :owner-id))
(old-node (ebox-runtime-index-get
owner-id (plist-get old-state :node-table)))
(node (ebox-runtime-index-get (node (ebox-runtime-index-get
owner-id (plist-get candidate-state :node-table))) owner-id (plist-get candidate-state :node-table)))
(snapshot (ebox-incremental--cached-layout-snapshot-details (snapshot (ebox-incremental--cached-layout-snapshot-details
buffer owner-id)) buffer owner-id))
(spans (plist-get snapshot :buffer-spans)) (spans (plist-get snapshot :buffer-spans))
(allocated-width
(plist-get (plist-get snapshot :external-footprint-signature)
:max-line-pixel-width))
(ancestors (ebox-incremental--ancestor-slot-signature (ancestors (ebox-incremental--ancestor-slot-signature
buffer old-state candidate-state owner-id))) buffer old-state candidate-state owner-id)))
(when (and node snapshot spans ancestors (when (and old-node node snapshot spans ancestors
(numberp allocated-width)
(equal (ebox-incremental--layout-slot-style-signature old-node)
(ebox-incremental--layout-slot-style-signature node))
(ebox-incremental--same-root-surface-identity-p (ebox-incremental--same-root-surface-identity-p
old-state candidate-state) old-state candidate-state)
(ebox-incremental--span-patch-identity-stable-p (ebox-incremental--span-patch-identity-stable-p
@ -8706,6 +8760,10 @@ complete replacement footprint before publishing any resulting spans."
(or (ebox-buffer--partial-line-slots-p spans) (or (ebox-buffer--partial-line-slots-p spans)
(ebox--spans-contiguous-lines-p spans)))) (ebox--spans-contiguous-lines-p spans))))
(list :owner-id owner-id :snapshot snapshot (list :owner-id owner-id :snapshot snapshot
:allocated-width
(and (ebox-incremental--content-independent-allocation-p
candidate-state owner-id)
allocated-width)
:dirty-set (plist-get prepared :dirty-set) :dirty-set (plist-get prepared :dirty-set)
:layer-recompose-p t :ancestor-slot-signature ancestors :layer-recompose-p t :ancestor-slot-signature ancestors
:layout-snapshot-detail-generation :layout-snapshot-detail-generation
@ -9667,6 +9725,11 @@ metadata; this predicate only authorizes the local attempt."
(and (memq :content changed-keys) (and (memq :content changed-keys)
(ebox-incremental--content-slot-stable-p (ebox-incremental--content-slot-stable-p
buffer slot-dirty candidate-state))) buffer slot-dirty candidate-state)))
(allocated-content-box-p
(and content-slot-evidence
(eq (plist-get old-node :ebox-kind) 'box)
(ebox-incremental--content-independent-allocation-p
candidate-state owner-id)))
(local-typography-p (local-typography-p
(and (not (memq :content changed-keys)) (and (not (memq :content changed-keys))
(cl-some (cl-some
@ -9711,7 +9774,8 @@ metadata; this predicate only authorizes the local attempt."
buffer old-state candidate-state owner-id)))) buffer old-state candidate-state owner-id))))
(allocated-width (allocated-width
(let* ((owner-slot (let* ((owner-slot
(if content-slot-evidence (if (and content-slot-evidence
(eq (plist-get old-node :ebox-kind) 'text))
(or (cadr content-slot-evidence) (or (cadr content-slot-evidence)
(car content-slot-evidence)) (car content-slot-evidence))
(car ancestor-slot-signature))) (car ancestor-slot-signature)))
@ -9765,10 +9829,13 @@ metadata; this predicate only authorizes the local attempt."
old-state candidate-state (list dirty)))) old-state candidate-state (list dirty))))
(ebox-incremental--owner-disjoint-from-scroll-p (ebox-incremental--owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state owner-id) buffer old-state prepared candidate-state owner-id)
;; Admit the new scroll coexistence case only for an exact ;; An allocated Box owns its changing line filler. Other
;; content extent. Length or width changes can alter an ;; owners beside retained scroll content still need an exact
;; ancestor's line filler outside the local owner's spans. ;; raw extent, because ancestor filler can lie outside their
;; local spans. Scroll dependency/binding proof above and
;; final slot validation remain mandatory in both cases.
(or (null (plist-get old-state :scroll-region-ids)) (or (null (plist-get old-state :scroll-region-ids))
allocated-content-box-p
(let ((before (plist-get (plist-get dirty :old-signature) (let ((before (plist-get (plist-get dirty :old-signature)
:content)) :content))
(after (plist-get (plist-get dirty :new-signature) (after (plist-get (plist-get dirty :new-signature)
@ -9835,10 +9902,10 @@ metadata; this predicate only authorizes the local attempt."
:ancestor-slot-signature ancestor-slot-signature :ancestor-slot-signature ancestor-slot-signature
:variable-content-p :variable-content-p
(and snapshot (and snapshot
;; With retained scroll siblings, the final rendered ;; Only a content-independent Box owns all filler affected
;; footprint must remain exact even when raw content has ;; by its changing text beside retained scroll siblings.
;; equal width under the current buffer's default font. (or (null (plist-get old-state :scroll-region-ids))
(null (plist-get old-state :scroll-region-ids)) allocated-content-box-p)
(memq :content changed-keys) (memq :content changed-keys)
(stringp (stringp
(plist-get (plist-get dirty :new-signature) :content)) (plist-get (plist-get dirty :new-signature) :content))
@ -10493,7 +10560,7 @@ role, and outside-complement compatibility before publication."
(and (eq projection-kind 'owner-scoped) (and (eq projection-kind 'owner-scoped)
(plist-get span-proof :allocated-width)) (plist-get span-proof :allocated-width))
:owner-scoped-proofs :owner-scoped-proofs
(or (and (memq projection-kind '(span-patch owner-scoped)) (or (and (memq projection-kind '(span-patch owner-scoped scroll-patch))
(ebox-incremental--owner-proof-list span-proof)) (ebox-incremental--owner-proof-list span-proof))
(and (eq projection-kind 'mixed-owner-reflow) (and (eq projection-kind 'mixed-owner-reflow)
(eq (plist-get (plist-get prepared (eq (plist-get (plist-get prepared
@ -10783,20 +10850,23 @@ role, and outside-complement compatibility before publication."
base) base)
(defun ebox-incremental--scroll-content-span-proof (defun ebox-incremental--scroll-content-span-proof
(buffer old-state prepared candidate-state) (buffer old-state prepared candidate-state &optional scroll-owner-id)
"Prove one content owner in a retained root scroll's content coordinates. "Prove one content owner in a retained root scroll's content coordinates.
The visible buffer may contain only part of the scroll content. Its cached The visible buffer may contain only part of the scroll content. Its cached
lines are a separate coordinate space, so run the ordinary owner and footprint lines are a separate coordinate space, so run the ordinary owner and footprint
proofs there rather than treating invisible owners as zero-sized. Nested proofs there rather than treating invisible owners as zero-sized. Nested
scrolls, non-content changes and uncached/partial owners retain their ordinary scrolls, non-content changes and uncached/partial owners retain their ordinary
fallback. The surface must still validate the complete cached replacement and fallback. The surface must still validate the complete cached replacement and
the visible replacement independently before publishing either one." the visible replacement independently before publishing either one.
SCROLL-OWNER-ID instead proves one nested scroll viewport in that root cache;
other scroll owners must be disjoint from the target."
(let* ((regions (plist-get old-state :scroll-region-ids)) (let* ((regions (plist-get old-state :scroll-region-ids))
(region (and (= (length regions) 1) (car regions))) (root (plist-get old-state :root-node))
(scroll (and region (gethash region ebox--scroll-global-state))) (region (if scroll-owner-id (plist-get root :region-id)
(and (= (length regions) 1) (car regions))))
(scroll (and region (gethash region (plist-get old-state :scroll-state-table))))
(next-scroll (and region (gethash region (next-scroll (and region (gethash region
(plist-get prepared :scroll-state-table)))) (plist-get prepared :scroll-state-table))))
(root (plist-get old-state :root-node))
(dirty-set (plist-get prepared :dirty-set)) (dirty-set (plist-get prepared :dirty-set))
(lines (plist-get scroll :content-lines))) (lines (plist-get scroll :content-lines)))
(when (and region scroll next-scroll lines (when (and region scroll next-scroll lines
@ -10804,9 +10874,24 @@ the visible replacement independently before publishing either one."
(eq (plist-get scroll :box) root) (eq (plist-get scroll :box) root)
(eq (plist-get next-scroll :box) (eq (plist-get next-scroll :box)
(plist-get candidate-state :root-node)) (plist-get candidate-state :root-node))
(= (hash-table-count (plist-get prepared :scroll-state-table)) 1) (if scroll-owner-id
(and (not (equal scroll-owner-id (plist-get root :node-id)))
(cl-every
(lambda (id)
(let ((owner (ebox--buffer-region-render-owner-node-id buffer id)))
(or (equal id region) (equal owner scroll-owner-id)
(and owner
(not (ebox--runtime-ancestor-id-p buffer owner scroll-owner-id))
(not (ebox--runtime-ancestor-id-p buffer scroll-owner-id owner))))))
regions))
(= (hash-table-count (plist-get prepared :scroll-state-table)) 1))
(= (length dirty-set) 1) (= (length dirty-set) 1)
(equal (plist-get (car dirty-set) :changed-keys) '(:content)) (if scroll-owner-id
(and (equal scroll-owner-id (plist-get (car dirty-set) :node-id))
(cl-every (lambda (key)
(memq key '(:scroll-offset :ebox-scroll-offset-controlled-p)))
(plist-get (car dirty-set) :changed-keys)))
(equal (plist-get (car dirty-set) :changed-keys) '(:content)))
(not (plist-get (car dirty-set) :children)) (not (plist-get (car dirty-set) :children))
(not (plist-get scroll :lazy-scroll-prefix-dirty)) (not (plist-get scroll :lazy-scroll-prefix-dirty))
(equal (plist-get scroll :scroll-offset) (equal (plist-get scroll :scroll-offset)
@ -10829,12 +10914,24 @@ the visible replacement independently before publishing either one."
(ebox-incremental--candidate-base-state (ebox-incremental--candidate-base-state
(cons (current-buffer) base)) (cons (current-buffer) base))
(ebox-incremental--candidate-state-for-slot-proof candidate) (ebox-incremental--candidate-state-for-slot-proof candidate)
(ebox--region-line-index (ebox--build-region-line-index)) (ebox--region-line-index (ebox--build-region-line-index scroll-owner-id))
(ebox--region-line-index-buffer (current-buffer)) (ebox--region-line-index-buffer (current-buffer))
(plan (ebox-incremental--layout-owner-plan (plan (if scroll-owner-id
(current-buffer) base candidate dirty-set)) (list (ebox--patch-op 'span-patch scroll-owner-id :dirty dirty-set))
(proof (ebox-incremental--span-patch-projection-proof (ebox-incremental--layout-owner-plan
(current-buffer) base input candidate plan))) (current-buffer) base candidate dirty-set)))
(proof
(if scroll-owner-id
(when-let* ((snapshot (ebox--ensure-layout-snapshot-details
(current-buffer) scroll-owner-id))
((plist-get snapshot :buffer-spans))
(width (plist-get (plist-get snapshot :external-footprint-signature)
:max-line-pixel-width)))
(list :owner-id scroll-owner-id :snapshot snapshot
:allocated-width width :nested-scroll-patch-p t
:scroll-content-coordinate-p t))
(ebox-incremental--span-patch-projection-proof
(current-buffer) base input candidate plan))))
(when proof (when proof
(plist-put proof :scroll-content-proof (plist-put proof :scroll-content-proof
(list :region-id region :lines lines :owner-plan plan (list :region-id region :lines lines :owner-plan plan
@ -10842,6 +10939,32 @@ the visible replacement independently before publishing either one."
:line-index ebox--region-line-index)) :line-index ebox--region-line-index))
proof))))))) proof)))))))
(defun ebox-incremental--layer-root-scroll-p
(old-state candidate-state prepared owner-id)
"Prove OWNER-ID only scrolls a retained composed root between both states.
OLD-STATE and CANDIDATE-STATE must share the complete cached content; PREPARED
must contain no source, geometry, paint, or display change besides its offset."
(let* ((old-root (plist-get old-state :root-node))
(root (plist-get candidate-state :root-node))
(region-id (plist-get root :region-id))
(old-scroll (gethash region-id (plist-get old-state :scroll-state-table)))
(scroll (gethash region-id (plist-get candidate-state :scroll-state-table))))
(and owner-id
(equal owner-id (plist-get old-root :node-id))
(equal owner-id (plist-get root :node-id))
(equal (plist-get old-state :display-signature)
(plist-get candidate-state :display-signature))
(cl-every (lambda (entry)
(and (equal owner-id (plist-get entry :node-id))
(cl-every (lambda (key)
(memq key '(:scroll-offset
:ebox-scroll-offset-controlled-p)))
(plist-get entry :changed-keys))))
(plist-get prepared :dirty-set))
(eq (plist-get old-scroll :content-lines)
(plist-get scroll :content-lines))
(ebox-layer-cached-root-scroll-p root scroll))))
(defun ebox-incremental--prepare-scoped-candidate (defun ebox-incremental--prepare-scoped-candidate
(buffer old-state next-root isolated-candidate-p (buffer old-state next-root isolated-candidate-p
&optional skip-span-proof-p scroll-transaction-p scroll-owner-id) &optional skip-span-proof-p scroll-transaction-p scroll-owner-id)
@ -10909,13 +11032,17 @@ whether NEXT-ROOT shares untouched published nodes."
(plist-get candidate-state :source-index))) (plist-get candidate-state :source-index)))
(plist-put candidate-state :selector-index-stale-p t))) (plist-put candidate-state :selector-index-stale-p t)))
(layer-owner-plan (layer-owner-plan
(ebox-incremental--layer-owner-plan (unless (and scroll-transaction-p
buffer old-state candidate-state (plist-get old-state :layered-p)
(or (plist-get prepared :dirty-set) (ebox-incremental--layer-root-scroll-p
(and scroll-transaction-p scroll-owner-id old-state candidate-state prepared scroll-owner-id))
(list (list :node-id scroll-owner-id (ebox-incremental--layer-owner-plan
:dirty-kind 'geometry buffer old-state candidate-state
:changed-keys '(:scroll-offset))))))) (or (plist-get prepared :dirty-set)
(and scroll-transaction-p scroll-owner-id
(list (list :node-id scroll-owner-id
:dirty-kind 'geometry
:changed-keys '(:scroll-offset))))))))
(formatting-proof (formatting-proof
(and (not layer-owner-plan) (not scroll-transaction-p) (and (not layer-owner-plan) (not scroll-transaction-p)
(not (cl-some (not (cl-some
@ -10952,13 +11079,15 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-incremental--paint-projection-p (ebox-incremental--paint-projection-p
buffer old-state prepared candidate-state owner-plan)) buffer old-state prepared candidate-state owner-plan))
(span-proof (span-proof
(unless (or paint-p mixed-proof (if (and scroll-transaction-p skip-span-proof-p (not layer-owner-plan))
(and skip-span-proof-p (not layer-owner-plan))) (ebox-incremental--scroll-content-span-proof
(or (ebox-incremental--span-patch-projection-proof buffer old-state prepared candidate-state scroll-owner-id)
(unless (or paint-p mixed-proof)
(or (ebox-incremental--span-patch-projection-proof
buffer old-state prepared candidate-state owner-plan buffer old-state prepared candidate-state owner-plan
path-copied-p) path-copied-p)
(ebox-incremental--scroll-content-span-proof (ebox-incremental--scroll-content-span-proof
buffer old-state prepared candidate-state)))) buffer old-state prepared candidate-state)))))
(projection-kind (projection-kind
(cond (cond
(mixed-proof 'mixed-owner-reflow) (mixed-proof 'mixed-owner-reflow)

View File

@ -60,9 +60,11 @@
(defun ebox-keymap-create (&rest options) (defun ebox-keymap-create (&rest options)
"Create an ordinary native keymap from OPTIONS. "Create an ordinary native keymap from OPTIONS.
`:activate' is an optional zero-argument callback for RET, return, SPC and `:activate' is an optional zero-argument callback for RET, return, SPC and
the primary mouse button. Nil omits these activation bindings. `:bindings' the primary mouse button. Repeated clicks each activate once; primary-button
is an alist of string key descriptions or key vectors paired with press and drag events do nothing, so they do not start native text selection.
zero-argument callbacks. Nil omits these activation bindings. `:bindings' is an alist of string key
descriptions or key vectors paired with zero-argument callbacks. Explicit
bindings override the repeated-click, press and drag defaults.
Callbacks are not invoked until a generated command runs. Keyboard commands Callbacks are not invoked until a generated command runs. Keyboard commands
use the current buffer; mouse commands use the event window's buffer without use the current buffer; mouse commands use the event window's buffer without
selecting a window or moving point. Duplicate or overlapping keys are errors. selecting a window or moving point. Duplicate or overlapping keys are errors.
@ -104,7 +106,20 @@ This function does not supply focus navigation, state or event propagation."
(error "Ebox keymap binding overlaps an existing key: %s" (error "Ebox keymap binding overlaps an existing key: %s"
(key-description key))) (key-description key)))
(define-key map key (ebox-interaction--command callback)) (define-key map key (ebox-interaction--command callback))
(push key keys)))) (push key keys)))
(when activate
;; A release-only map lets the preceding press enter mouse-drag-region;
;; double/triple clicks can then select words or whole lines. Claim
;; the primary-button sequence locally, preserving explicit handlers.
(dolist (event '(double-mouse-1 triple-mouse-1
down-mouse-1 double-down-mouse-1
triple-down-mouse-1 drag-mouse-1
double-drag-mouse-1 triple-drag-mouse-1))
(unless (lookup-key map (vector event))
(define-key map (vector event)
(if (memq event '(double-mouse-1 triple-mouse-1))
(lookup-key map [mouse-1])
#'ignore))))))
map)) map))
(defun ebox-interaction--navigable-keymap-p (map) (defun ebox-interaction--navigable-keymap-p (map)

View File

@ -35,6 +35,10 @@
(defvar ebox-layer--portals nil (defvar ebox-layer--portals nil
"Candidate-local absolute nodes awaiting root composition.") "Candidate-local absolute nodes awaiting root composition.")
(defvar ebox-layer--piece-width-cache nil
"Original row prefix widths scoped to one host composition.
Keys are source strings by identity. Never retained between renders.")
(defvar ebox-layer--active-p 'unknown (defvar ebox-layer--active-p 'unknown
"Whether the current candidate contains layered layout. "Whether the current candidate contains layered layout.
Retained publication supplies a known boolean; isolated rendering resolves Retained publication supplies a known boolean; isolated rendering resolves
@ -72,6 +76,18 @@ the fact once for its spatial root.")
(or (eq (plist-get node :layer) 'root) (or (eq (plist-get node :layer) 'root)
(cl-some #'ebox-layer-portals-p (ebox-tree-node-children node))))) (cl-some #'ebox-layer-portals-p (ebox-tree-node-children node)))))
(defun ebox-layer-visible-portals-p (node)
"Return non-nil when NODE can paint an absolute root projection.
Hidden owners suppress their complete logical subtree. Keep this visible
dependency separate from structural portal detection: hidden portals remain
in the retained input and can become visible in a later candidate."
(and (listp node)
(not (eq (plist-get node :visibility) 'hidden))
(or (and (eq (plist-get node :position) 'absolute)
(eq (plist-get node :layer) 'root))
(cl-some #'ebox-layer-visible-portals-p
(ebox-tree-node-children node)))))
(defun ebox-layer--owned-p (line position region) (defun ebox-layer--owned-p (line position region)
"Whether LINE at POSITION visibly belongs to REGION, excluding margins." "Whether LINE at POSITION visibly belongs to REGION, excluding margins."
(and (not (cl-some (lambda (role) (and (not (cl-some (lambda (role)
@ -83,16 +99,46 @@ the fact once for its spatial root.")
(equal region (get-text-property position (cdr role) line))) (equal region (get-text-property position (cdr role) line)))
ebox-region-types)))) ebox-region-types))))
(defun ebox-layer-cached-root-scroll-p (root state)
"Return non-nil when ROOT can slice STATE's complete composed content.
This proves only the cached content geometry. Callers must separately prove
a pure root scroll transaction with unchanged source and display context.
Root projections and anchored provider chains are already composed in this
content coordinate system, before the root wrapper applies its scroll offset.
Nested scroll changes and source updates cannot use this root-only proof."
(and root state
(equal (plist-get root :node-id)
(plist-get (plist-get state :box) :node-id))
(eq (ebox-get root :overflow) 'scroll)
(plist-get state :content-lines)
(or (plist-get state :content-lines-complete-p)
(and (not (plist-get state :render-content-prefix))
(not (plist-get state :materialize-content-lines))))
(not (plist-get state :lazy-scroll-prefix-dirty))
(not (plist-get state :lazy-scroll-window-refresh-required))))
(defun ebox-layer--prefix-width (line end)
"Return LINE's original prefix width at END within this composition."
(if (zerop end) 0
(let ((widths (or (gethash line ebox-layer--piece-width-cache)
(puthash line (make-hash-table :test #'eql)
ebox-layer--piece-width-cache))))
(or (gethash end widths)
(puthash end (ebox--substring-pixel-width line 0 end) widths)))))
(defun ebox-layer--pieces (lines node) (defun ebox-layer--pieces (lines node)
"Return NODE's visible source intervals in LINES, before positioning." "Return NODE's visible source intervals in LINES, before positioning."
(let ((region (ebox--ensure-region-id node)) pieces) (let ((region (ebox--ensure-region-id node))
(ebox-layer--piece-width-cache
(or ebox-layer--piece-width-cache (make-hash-table :test #'eq)))
pieces)
(cl-loop for line in lines for row from 0 do (cl-loop for line in lines for row from 0 do
(let ((start 0) (limit (length line))) (let ((start 0) (limit (length line)))
(while (< start limit) (while (< start limit)
(let ((end (next-property-change start line limit))) (let ((end (next-property-change start line limit)))
(when (ebox-layer--owned-p line start region) (when (ebox-layer--owned-p line start region)
(let ((x (ebox--substring-pixel-width line 0 start)) (let ((x (ebox-layer--prefix-width line start))
(right (ebox--substring-pixel-width line 0 end))) (right (ebox-layer--prefix-width line end)))
(when (> right x) (when (> right x)
;; Keep the full row as source so shaping and shared ;; Keep the full row as source so shaping and shared
;; display objects are measured in their own context. ;; display objects are measured in their own context.
@ -310,7 +356,8 @@ Only candidate-local lists of visible paint records are sorted."
(eq (plist-get child :position) 'absolute)) children)) (eq (plist-get child :position) 'absolute)) children))
(base (funcall renderer flow))) (base (funcall renderer flow)))
(if ebox--intrinsic-layout-measurement base (if ebox--intrinsic-layout-measurement base
(let* ((lines (ebox-string-lines base)) (let* ((ebox-layer--piece-width-cache (make-hash-table :test #'eq))
(lines (ebox-string-lines base))
(width (or (ebox--wrapper-content-viewport-pixel host) (width (or (ebox--wrapper-content-viewport-pixel host)
(if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0))) (if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0)))
(flow-height (length lines)) (flow-height (length lines))
@ -335,7 +382,8 @@ Only candidate-local lists of visible paint records are sorted."
(plist-get piece :end))) records))))) (plist-get piece :end))) records)))))
(unless (eq host ebox-layer--render-root) (unless (eq host ebox-layer--render-root)
(dolist (child absolute) (dolist (child absolute)
(when (eq (plist-get child :layer) 'root) (when (and (eq (plist-get child :layer) 'root)
(not (eq (plist-get child :visibility) 'hidden)))
(cl-pushnew child ebox-layer--portals :test #'eq))) (cl-pushnew child ebox-layer--portals :test #'eq)))
(setq absolute (cl-remove-if (lambda (child) (setq absolute (cl-remove-if (lambda (child)
(eq (plist-get child :layer) 'root)) absolute))) (eq (plist-get child :layer) 'root)) absolute)))

View File

@ -1369,7 +1369,8 @@ Return its empty inline extent through `ebox--box-content-empty-width'."
(_ (error "Ebox Box has unsupported LayoutConfig: %S" kind)))))) (_ (error "Ebox Box has unsupported LayoutConfig: %S" kind))))))
(if (or (ebox-layer-host-p box) (if (or (ebox-layer-host-p box)
(and (eq box ebox-layer--render-root) (and (eq box ebox-layer--render-root)
(or ebox-layer--portals (ebox-layer-portals-p box)))) (or ebox-layer--portals
(ebox-layer-visible-portals-p box))))
(ebox-layer-render-children box children render-flow) (ebox-layer-render-children box children render-flow)
(funcall render-flow children))))))) (funcall render-flow children)))))))
(plist-put box :ebox-content-layout-complete-p (plist-put box :ebox-content-layout-complete-p
@ -1731,7 +1732,12 @@ one carrier line preserves their paint without inventing a content line."
(defun ebox--format-content (box) (defun ebox--format-content (box)
"Format BOX content (wrap and justify) returning a string." "Format BOX content (wrap and justify) returning a string."
(or (and (not (ebox-layer-active-p box)) (or (and (or (not (ebox-layer-active-p box))
;; Only the staged root viewport may replay composed layers.
;; Fresh lazy producers and nested owners still compose fully.
(and (eq box ebox-layer--render-root)
(ebox-layer-cached-root-scroll-p
box ebox--scroll-window-cached-state)))
(ebox--format-scroll-window-content box)) (ebox--format-scroll-window-content box))
(ebox--format-content-string box (ebox--box-content box)))) (ebox--format-content-string box (ebox--box-content box))))
@ -2134,7 +2140,12 @@ This function owns spatial layout only; it never creates a live TP surface."
(cond (cond
((eq (plist-get node :ebox-kind) 'text) ((eq (plist-get node :ebox-kind) 'text)
(ebox--render-text-node node)) (ebox--render-text-node node))
((eq type 'box) (ebox--render-box node)) ((eq type 'box)
(if (eq (ebox-get node :visibility) 'hidden)
;; Keep hidden geometry, but do not let descendant portals
;; escape this visibility boundary into an outer composition.
(let ((ebox-layer--portals nil)) (ebox--render-box node))
(ebox--render-box node)))
((eq type 'concat) (ebox--render-concat node)) ((eq type 'concat) (ebox--render-concat node))
((eq type 'stack) (ebox--render-stack node)) ((eq type 'stack) (ebox--render-stack node))
((eq type 'flex) (ebox--render-flex node)) ((eq type 'flex) (ebox--render-flex node))

View File

@ -253,7 +253,8 @@
ebox--render-string-pixel-width-cache ebox--render-string-pixel-width-cache
ebox--rendered-uniform-width-table ebox--rendered-intrinsic-size-table ebox--rendered-uniform-width-table ebox--rendered-intrinsic-size-table
ebox--runtime-ancestor-cache ebox--runtime-ancestor-cache
ebox--scroll-window-cached-state ebox--space-pixel-cache ebox--scroll-window-cached-state :rendered-content-line-cells
ebox--space-pixel-cache
ebox--string-pixel-width-cache ebox--string-pixel-width-cache
ebox--string-pixel-width-cache-ring ebox--string-pixel-width-cache-ring
ebox--viewport-dependent-node-ids-cache ebox--viewport-dependent-node-ids-cache
@ -304,6 +305,9 @@ rather than relying on an implicit default.")
(:symbol ebox-layer--portals (:symbol ebox-layer--portals
:reason dynamically-bound-render-collection :reason dynamically-bound-render-collection
:evidence let-bound-per-spatial-root-and-discarded-with-the-candidate-render) :evidence let-bound-per-spatial-root-and-discarded-with-the-candidate-render)
(:symbol ebox-layer--piece-width-cache
:reason dynamically-bound-composition-index
:evidence let-bound-per-layer-host-and-never-retained-between-renders)
(:symbol ebox--string-pixel-width-cache-ring-index (:symbol ebox--string-pixel-width-cache-ring-index
:reason numeric-eviction-cursor :reason numeric-eviction-cursor
:evidence value-is-an-integer-ring-position) :evidence value-is-an-integer-ring-position)

View File

@ -219,6 +219,10 @@ the winning client state; otherwise only the candidate is disposable."
"ebox" (state)) "ebox" (state))
(declare-function ebox--scroll-state-retained-window-ready-p (declare-function ebox--scroll-state-retained-window-ready-p
"ebox" (state)) "ebox" (state))
(declare-function ebox--scroll-state-set-lines
"ebox" (state content-lines &optional rendered-lines))
(declare-function ebox--surface-nested-scroll-cached-p
"ebox" (scroll &optional delta))
(declare-function ebox--scroll-box-contains-grid-p (declare-function ebox--scroll-box-contains-grid-p
"ebox" (box)) "ebox" (box))
(declare-function ebox--scroll-line-region-span (declare-function ebox--scroll-line-region-span
@ -513,34 +517,66 @@ left on the conservative full fragment scanner path."
(or (gethash line ebox-surface--scroll-line-fragment-cache) (or (gethash line ebox-surface--scroll-line-fragment-cache)
(let ((fragments (ebox-surface--rendered-fragments line))) (let ((fragments (ebox-surface--rendered-fragments line)))
(when (and fragments (when (and fragments
(plist-get (car fragments) :paint-role-ids)
(plist-get (car (last fragments)) :paint-role-ids)
(cl-every (lambda (fragment) (cl-every (lambda (fragment)
(plist-get fragment :role-ids)) (plist-get fragment :role-ids))
fragments)) fragments))
(puthash line fragments ebox-surface--scroll-line-fragment-cache) (puthash line fragments ebox-surface--scroll-line-fragment-cache)
fragments)))) fragments))))
(defun ebox-surface--map-scroll-fragments (lines visit)
"Call VISIT for LINES' cached fragments and intervening newline ownership.
VISIT receives a fragment, its line's absolute offset and its line index.
Return nil if a line needs the full scanner, otherwise return non-nil.
Unowned separators inherit the previous and next roles just as they do in
`ebox-surface--rendered-fragments'. Cached line templates stay immutable."
(cl-block ebox-surface--map-scroll-fragments
(let ((offset 0) (line-index 0)
(template (and lines
(ebox-surface--scroll-line-fragment-template
(car lines)))))
(while lines
(unless template
(cl-return-from ebox-surface--map-scroll-fragments nil))
(let ((length (length (car lines)))
previous-roles)
(dolist (fragment template)
(funcall visit fragment offset line-index)
(setq previous-roles (plist-get fragment :role-ids)))
(setq lines (cdr lines))
(when lines
(setq template
(ebox-surface--scroll-line-fragment-template (car lines)))
(unless template
(cl-return-from ebox-surface--map-scroll-fragments nil))
(funcall visit
(list :text-source-p t :start length :end (1+ length)
:role-ids
(delete-dups
(append (copy-sequence previous-roles)
(copy-sequence
(plist-get (car template) :role-ids)))))
offset line-index))
(setq offset (+ offset length 1)
line-index (1+ line-index))))
t)))
(defun ebox-surface--scroll-fragment-data (lines rendered) (defun ebox-surface--scroll-fragment-data (lines rendered)
"Return offset fragments for visible LINES in RENDERED, or nil. "Return offset fragments for visible LINES in RENDERED, or nil.
Each cached line template is copied only at the plist spine and points at the Each cached line template is copied only at the plist spine and points at the
single joined RENDERED string. The text and role values remain candidate single joined RENDERED string. The text and role values remain candidate
owned; no per-fragment substring is allocated." owned; no per-fragment substring is allocated."
(cl-block ebox-surface--scroll-fragment-data (let (result)
(let ((offset 0) (when (ebox-surface--map-scroll-fragments
(line-index 0) lines
result) (lambda (fragment offset line-index)
(dolist (line lines) (let ((copy (copy-sequence fragment)))
(let ((template (ebox-surface--scroll-line-fragment-template line))) (plist-put copy :text rendered)
(unless template (plist-put copy :start (+ offset (plist-get fragment :start)))
(cl-return-from ebox-surface--scroll-fragment-data nil)) (plist-put copy :end (+ offset (plist-get fragment :end)))
(dolist (fragment template) (plist-put copy :line line-index)
(let ((copy (copy-sequence fragment))) (push copy result))))
(plist-put copy :text rendered)
(plist-put copy :start (+ offset (plist-get fragment :start)))
(plist-put copy :end (+ offset (plist-get fragment :end)))
(plist-put copy :line line-index)
(push copy result))))
(setq offset (+ offset (length line) 1))
(setq line-index (1+ line-index)))
(nreverse result)))) (nreverse result))))
(defun ebox-surface--scroll-lines-owned-p (lines) (defun ebox-surface--scroll-lines-owned-p (lines)
@ -1896,8 +1932,10 @@ the projection roots a second time."
(ebox-surface--native-patch-result (ebox-surface--native-patch-result
context projection state output node-objects region-objects)) context projection state output node-objects region-objects))
(and (or (null property-contributions) retained-owned-ranges) (and (or (null property-contributions) retained-owned-ranges)
(memq projection-kind (or (memq projection-kind
'(span-patch owner-scoped mixed-owner-reflow)) '(span-patch owner-scoped mixed-owner-reflow))
(and (eq projection-kind 'scroll-patch)
(plist-get state :nested-scroll-patch-proof)))
(ebox-surface--span-coordinate-result (ebox-surface--span-coordinate-result
context projection state output previous-owned-ranges context projection state output previous-owned-ranges
property-contributions retained-owned-ranges)) property-contributions retained-owned-ranges))
@ -2267,11 +2305,15 @@ RETAINED-OWNED-RANGES witnesses unchanged ownership when it is that snapshot."
(or (plist-get state :mixed-owner-fragment-data) (or (plist-get state :mixed-owner-fragment-data)
(plist-get state :span-patch-fragment-data)))) (plist-get state :span-patch-fragment-data))))
(rebased (rebased
(if retained-origin ;; Layer composition can replace visible owners without
(cons t previous-owned-ranges) ;; moving a single character. Coordinates alone cannot
(and (null property-contributions) previous-owned-ranges ;; retain its mounts; rebuild them from the new role runs.
(ebox-surface--rebase-owned-ranges (and (not (plist-get state :layer-recompose-p))
previous-owned-ranges patches (length rendered))))) (if retained-origin
(cons t previous-owned-ranges)
(and (null property-contributions) previous-owned-ranges
(ebox-surface--rebase-owned-ranges
previous-owned-ranges patches (length rendered))))))
(batch (batch
(ebox-surface--coordinate-batch (ebox-surface--coordinate-batch
state rendered previous-owned-ranges state rendered previous-owned-ranges
@ -3175,6 +3217,7 @@ valid because their declarative layout and viewport did not change."
(member region-id producer-region-ids)) (member region-id producer-region-ids))
(producer-keys (producer-keys
'(:content-lines :rendered-content-lines '(:content-lines :rendered-content-lines
:rendered-content-line-cells
:content-lines-complete-p :render-content-prefix :content-lines-complete-p :render-content-prefix
:materialize-content-lines :region-line-bounds-index :materialize-content-lines :region-line-bounds-index
:region-line-span-index :region-line-span-index-deferred :region-line-span-index :region-line-span-index-deferred
@ -3588,8 +3631,9 @@ old allocated width while the owner changes its natural content length."
lines))) lines)))
(defun ebox-surface--retained-external-owner-suffix (defun ebox-surface--retained-external-owner-suffix
(buffer spans region-ids) (buffer spans region-ids &optional allow-empty-p)
"Return one common retained content-owner suffix outside REGION-IDS." "Return one common retained content-owner suffix outside REGION-IDS.
ALLOW-EMPTY-P permits t to represent a proved empty suffix in a root cache."
(let ((local (ebox--region-id-set region-ids)) common seen valid) (let ((local (ebox--region-id-set region-ids)) common seen valid)
(setq valid t) (setq valid t)
(with-current-buffer buffer (with-current-buffer buffer
@ -3612,7 +3656,7 @@ old allocated width while the owner changes its natural content length."
(unless (equal common external) (setq valid nil)) (unless (equal common external) (setq valid nil))
(setq common external seen t))) (setq common external seen t)))
(setq position (max (1+ position) next))))))) (setq position (max (1+ position) next)))))))
(and valid seen common))) (and valid seen (or common allow-empty-p))))
(defun ebox-surface--owner-needs-ancestor-paint-p (state owner-id) (defun ebox-surface--owner-needs-ancestor-paint-p (state owner-id)
"Return non-nil if STATE's detached OWNER-ID omits enclosing effects. "Return non-nil if STATE's detached OWNER-ID omits enclosing effects.
@ -3719,7 +3763,9 @@ Compare resolved paint and surface properties, preserving opaque identities."
((numberp width))) ((numberp width)))
(ebox--flex-copy-node-for-size node 'row width nil nil))) (ebox--flex-copy-node-for-size node 'row width nil nil)))
((and node allocated-width variable-content-p) ((and node allocated-width variable-content-p)
(plist-put (copy-sequence node) :width allocated-width)) (if (eq (plist-get node :ebox-kind) 'text)
(plist-put (copy-sequence node) :width allocated-width)
(ebox--flex-copy-node-for-size node 'row allocated-width nil nil)))
(t node)))) (t node))))
(defun ebox-surface--retained-slot-owner-text (defun ebox-surface--retained-slot-owner-text
@ -3838,6 +3884,33 @@ Return nil on a proof miss. The caller still validates the final footprint."
(push line result))))) (push line result)))))
(mapconcat #'identity (nreverse result) "\n")))) (mapconcat #'identity (nreverse result) "\n"))))
(defun ebox-surface--retain-slot-paint-origins
(buffer previous-state spans lines)
"Carry known paint baselines from BUFFER's PREVIOUS-STATE into LINES.
SPANS and LINES already have identical character coordinates. Preserve only
captured baselines; an unknown baseline remains unknown for later proofs."
(let ((fragments (ebox-surface--materialized-fragment-ledger previous-state))
(origin (with-current-buffer buffer (point-min))))
(cl-loop
for span in spans for line in lines
do
(let ((start (- (car span) origin))
(end (- (cdr span) origin)))
(while (and fragments (<= (plist-get (car fragments) :end) start))
(setq fragments (cdr fragments)))
(let ((remaining fragments))
(while (and remaining (< (plist-get (car remaining) :start) end))
(let ((fragment (pop remaining)))
(when (plist-get fragment :face-baseline-known-p)
(put-text-property
(- (max start (plist-get fragment :start)) start)
(- (min end (plist-get fragment :end)) start)
ebox--paint-origin-property
(ebox--paint-origin-create
:baseline (copy-tree (plist-get fragment :face-baseline)))
line))))))))
lines)
(defun ebox-surface--retained-owner-text (defun ebox-surface--retained-owner-text
(buffer previous-state state owner-id spans replacement allocated-width (buffer previous-state state owner-id spans replacement allocated-width
variable-content-p variable-content-max-width variable-content-p variable-content-max-width
@ -3879,10 +3952,10 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(old (ebox-runtime-index-get old-id (plist-get previous-state :node-table))) (old (ebox-runtime-index-get old-id (plist-get previous-state :node-table)))
(new (ebox-runtime-index-get new-id (plist-get state :node-table)))) (new (ebox-runtime-index-get new-id (plist-get state :node-table))))
(and (equal old-id new-id) (and (equal old-id new-id)
(if role-owned-lines-p (if (or role-owned-lines-p variable-content-p)
;; The canonical candidate may replace an ;; The canonical candidate may replace a
;; anonymous source handle while retaining its ;; source handle while retaining its
;; runtime slot. This mode rebuilds fragment ;; runtime slot. These modes rebuild fragment
;; metadata; verify that slot's full identity ;; metadata; verify that slot's full identity
;; topology instead of retaining the old handle. ;; topology instead of retaining the old handle.
(and (eq (plist-get old :ebox-type) (and (eq (plist-get old :ebox-type)
@ -3975,7 +4048,7 @@ Proof mismatches return nil; unexpected rendering errors propagate."
line) line)
(setq position end))) (setq position end)))
(push line result)))))) (push line result))))))
(and valid (mapconcat #'identity (nreverse result) "\n"))))))) (and valid (ebox-lines-join (nreverse result))))))))
(defun ebox-surface--owner-patch-candidate (defun ebox-surface--owner-patch-candidate
(buffer state owner-id allocated-width (buffer state owner-id allocated-width
@ -4094,31 +4167,113 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(plist-get (plist-get details :footprint) (plist-get (plist-get details :footprint)
:char-lengths))))))))) :char-lengths)))))))))
(defun ebox-surface--layer-enclosing-paint
(buffer state owner-id spans rendered)
"Restore enclosing paint from BUFFER and STATE on RENDERED.
OWNER-ID's SPANS delimit the retained slots.
Only ancestor roles and their resolved face contributions are retained.
Every published property run in a slot must agree on those effects, so a
newly exposed child cannot inherit the covered child's paint or interaction.
Return nil when the enclosing effect varies inside a retained slot."
(catch 'enclosing-paint-miss
(let ((parents (plist-get state :parent-table))
(nodes (plist-get state :node-table))
(parent owner-id)
(lines (ebox-string-lines rendered))
(ebox--paint-origin-capture-p t)
(ebox--render-owned-text-values
(plist-get state :render-owned-text-values))
ancestors result)
(while (setq parent (ebox-runtime-index-get parent parents))
(when-let* ((box (ebox-fragment-style-source-node
(ebox-runtime-index-get parent nodes))))
(push box ancestors)))
(setq ancestors (nreverse ancestors))
(unless (= (length spans) (length lines))
(throw 'enclosing-paint-miss nil))
(with-current-buffer buffer
(cl-loop
for span in spans for source in lines
do
(let* ((published (buffer-substring (car span) (cdr span)))
(position 0) (limit (length published))
(line (copy-sequence source))
seen effects)
(while (< position limit)
(let ((roles (ebox-surface--role-ids-at published position))
faces properties)
(dolist (box ancestors)
(let* ((region (plist-get box :region-id))
(own-roles
(cl-loop for (role . id) in roles
when (equal id region) collect role)))
(setq faces
(append faces
(ebox-surface--region-face-contributions
box own-roles t)))
(dolist (role own-roles)
(when-let* ((property (alist-get role ebox-region-types)))
(setq properties (plist-put properties property region))))))
(let ((current (cons faces properties)))
(when (and seen (not (equal effects current)))
(throw 'enclosing-paint-miss nil))
(setq effects current seen t)))
(setq position (next-property-change position published limit)))
(when (> (length line) 0)
(add-text-properties 0 (length line) (cdr effects) line)
(dolist (face (car effects))
(ebox--add-render-face! line 0 (length line) face t)))
(push line result))))
(ebox-lines-join (nreverse result)))))
(defun ebox-surface--range-patch-candidate (buffer state proof) (defun ebox-surface--range-patch-candidate (buffer state proof)
"Return PROOF's exact equal-line Range replacement candidate, or nil." "Return PROOF's exact equal-line Range or layer replacement candidate, or nil.
Render STATE's candidate into BUFFER's retained owner allocation."
(let* ((owner-id (plist-get proof :owner-id)) (let* ((owner-id (plist-get proof :owner-id))
(old-snapshot (plist-get proof :snapshot)) (old-snapshot (plist-get proof :snapshot))
(spans (plist-get old-snapshot :buffer-spans)) (spans (plist-get old-snapshot :buffer-spans))
(node (ebox-runtime-index-get owner-id (plist-get state :node-table))) (node (ebox-runtime-index-get owner-id (plist-get state :node-table)))
(render-node (render-node
(if (and node (numberp (plist-get proof :allocated-width))) (cond
(plist-put (copy-sequence node) ((or (plist-get proof :layer-recompose-p)
:width (plist-get proof :allocated-width)) (plist-get proof :nested-scroll-patch-p))
node)) (ebox-surface--owner-slot-render-node
buffer state owner-id old-snapshot
(plist-get proof :allocated-width) nil nil nil))
((and node (numberp (plist-get proof :allocated-width)))
(plist-put (copy-sequence node)
:width (plist-get proof :allocated-width)))
(t node)))
(rendered (rendered
(and render-node (and render-node
(ebox-surface--render-candidate-node state render-node))) (ebox-surface--render-candidate-node state render-node)))
(rendered
(if (and rendered (plist-get proof :nested-scroll-patch-p))
(ebox-surface--nested-scroll-owned-lines
buffer spans rendered (plist-get node :region-id))
rendered))
(external-owners (external-owners
(and rendered (and rendered
(ebox-surface--retained-external-owner-suffix (ebox-surface--retained-external-owner-suffix
buffer spans (plist-get old-snapshot :region-ids)))) buffer spans (plist-get old-snapshot :region-ids)
(plist-get proof :scroll-content-coordinate-p))))
(rendered (rendered
(and external-owners (and external-owners
(ebox--add-content-owners rendered external-owners))) (ebox--add-content-owners rendered
(unless (eq external-owners t) external-owners))))
(rendered
(if (and rendered (or (plist-get proof :layer-recompose-p)
(plist-get proof :nested-scroll-patch-p)))
(ebox-surface--layer-enclosing-paint
buffer state owner-id spans rendered)
rendered))
(replacement (replacement
(and rendered (and rendered
(ebox-buffer--rendered-in-existing-slots (or (ebox-buffer--rendered-in-existing-slots
spans rendered nil t))) spans rendered nil t)
(and (plist-get proof :scroll-content-coordinate-p)
(cl-every #'ebox--span-whole-line-p spans)
rendered))))
(lines (and replacement (ebox-string-lines replacement))) (lines (and replacement (ebox-string-lines replacement)))
(new-spans (new-spans
(and lines (ebox-surface--projected-span-positions spans lines))) (and lines (ebox-surface--projected-span-positions spans lines)))
@ -4134,7 +4289,10 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(ebox--project-parent-slot-signature (ebox--project-parent-slot-signature
(plist-get old-snapshot :parent-slot-signature) (plist-get old-snapshot :parent-slot-signature)
new-footprint))) new-footprint)))
(new-region-ids (and node (ebox--node-all-region-ids node))) (new-region-ids
(and node (if (plist-get proof :nested-scroll-patch-p)
(plist-get old-snapshot :region-ids)
(ebox--node-all-region-ids node))))
(new-roles (new-roles
(and replacement (and replacement
(ebox-surface--rendered-role-topology-signature (ebox-surface--rendered-role-topology-signature
@ -4238,6 +4396,7 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(lines (plist-get proof :lines)) (lines (plist-get proof :lines))
(owner-proof (car (plist-get state :owner-scoped-proofs))) (owner-proof (car (plist-get state :owner-scoped-proofs)))
(owner-id (plist-get owner-proof :owner-id)) (owner-id (plist-get owner-proof :owner-id))
(nested-scroll-p (plist-get owner-proof :nested-scroll-patch-p))
cached-candidate cached-lines changes output) cached-candidate cached-lines changes output)
(when (and old-scroll next-scroll (when (and old-scroll next-scroll
(eq lines (plist-get old-scroll :content-lines)) (eq lines (plist-get old-scroll :content-lines))
@ -4279,16 +4438,21 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(ebox-incremental--candidate-base-state (ebox-incremental--candidate-base-state
(cons (current-buffer) base))) (cons (current-buffer) base)))
(setq cached-candidate (setq cached-candidate
(ebox-surface--owner-patch-candidate (if nested-scroll-p
(current-buffer) candidate owner-id (ebox-surface--nested-scroll-patch-candidate
(plist-get owner-proof :allocated-width) (current-buffer) base candidate
(plist-get owner-proof :variable-content-p) (plist-put (copy-sequence owner-proof) :snapshot
(plist-get owner-proof :variable-content-max-width) (gethash owner-id (plist-get base :layout-snapshots))))
(plist-get owner-proof :role-owned-lines-p) (ebox-surface--owner-patch-candidate
(plist-get owner-proof :changed-keys) (current-buffer) candidate owner-id
(plist-get owner-proof :allocation-closure-p) (plist-get owner-proof :allocated-width)
(plist-get owner-proof :retain-external-owner-suffix-p) (plist-get owner-proof :variable-content-p)
base)) (plist-get owner-proof :variable-content-max-width)
(plist-get owner-proof :role-owned-lines-p)
(plist-get owner-proof :changed-keys)
(plist-get owner-proof :allocation-closure-p)
(plist-get owner-proof :retain-external-owner-suffix-p)
base)))
(when cached-candidate (when cached-candidate
(setq cached-lines (copy-sequence lines)) (setq cached-lines (copy-sequence lines))
(cl-mapc (cl-mapc
@ -4309,6 +4473,14 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(plist-get previous-state :node-table))) (plist-get previous-state :node-table)))
(visible (ebox--region-ids-visible-in-buffer-p (visible (ebox--region-ids-visible-in-buffer-p
buffer (ebox--node-all-region-ids node)))) buffer (ebox--node-all-region-ids node))))
(when (and nested-scroll-p visible)
;; The cache and the mounted window are distinct coordinate spaces.
;; A clipped owner must independently prove its mounted allocation.
(when-let* ((snapshot (ebox--ensure-layout-snapshot-details buffer owner-id)))
(plist-put state :owner-scoped-proofs
(list (let ((mounted (plist-put (copy-sequence owner-proof) :snapshot snapshot)))
(cl-remf mounted :scroll-content-coordinate-p)
mounted)))))
(setq output (setq output
(if visible (if visible
(ebox-surface--buffer-span-patch-output buffer previous-state state) (ebox-surface--buffer-span-patch-output buffer previous-state state)
@ -4337,14 +4509,18 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
bounds) bounds)
(plist-put private :region-line-bounds-index copy))) (plist-put private :region-line-bounds-index copy)))
(when rendered (when rendered
(dolist (change changes) ;; Auto-width scroll chrome can measure its candidate children
(let* ((number (car change)) ;; while shaping a cached line. Those children belong to this
(old (nth number rendered)) ;; input generation, even though BUFFER still holds the old one.
(new (car (ebox--scroll-rendered-content-lines (let ((ebox--render-source-index (plist-get state :source-index)))
(plist-get private :box) (list (nth 2 change)) (dolist (change changes)
region number)))) (let* ((number (car change))
(setcar (nthcdr number rendered) new) (old (nth number rendered))
(push (list number old new) rendered-changes)))) (new (car (ebox--scroll-rendered-content-lines
(plist-get private :box) (list (nth 2 change))
region number))))
(setcar (nthcdr number rendered) new)
(push (list number old new) rendered-changes)))))
(plist-put private :content-lines cached-lines) (plist-put private :content-lines cached-lines)
(plist-put private :region-line-span-index (plist-put private :region-line-span-index
(ebox-surface--scroll-replace-index-lines (ebox-surface--scroll-replace-index-lines
@ -4357,9 +4533,19 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(ebox-surface--scroll-replace-index-lines (ebox-surface--scroll-replace-index-lines
(plist-get private :rendered-region-line-span-index) (plist-get private :rendered-region-line-span-index)
rendered-changes)) rendered-changes))
;; The validated owner preserves line and property topology; (if nested-scroll-p
;; line bounds/membership stay valid, character hints do not. ;; A viewport reveals different child roles. Rebuild the
(cl-remf private :region-line-span-hints) ;; content membership/bounds and retire the old slice index.
(progn
(plist-put private :content-lines lines)
(setq private (ebox--scroll-state-set-lines private cached-lines rendered))
(setq private (ebox--scroll-window-rebind-state-producers
private (plist-get private :box)))
(cl-remf private :lazy-scroll-prefix-dirty)
(cl-remf private :lazy-scroll-window-refresh-required))
;; Content-only patches preserve role topology, but their
;; character hints must be derived from the replacement text.
(cl-remf private :region-line-span-hints))
(puthash region private (plist-get state :scroll-state-table))) (puthash region private (plist-get state :scroll-state-table)))
(cl-remf state :scroll-content-proof)))) (cl-remf state :scroll-content-proof))))
output))) output)))
@ -4378,6 +4564,9 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(mapcar (mapcar
(lambda (proof) (lambda (proof)
(cond (cond
((plist-get proof :nested-scroll-patch-p)
(ebox-surface--nested-scroll-patch-candidate
buffer previous-state state proof))
((plist-get proof :layer-recompose-p) ((plist-get proof :layer-recompose-p)
(ebox-surface--layer-patch-candidate (ebox-surface--layer-patch-candidate
buffer previous-state state proof)) buffer previous-state state proof))
@ -4394,7 +4583,10 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(plist-get proof :changed-keys) (plist-get proof :changed-keys)
(plist-get proof :allocation-closure-p) (plist-get proof :allocation-closure-p)
(plist-get proof :retain-external-owner-suffix-p) (plist-get proof :retain-external-owner-suffix-p)
(and (= (length proofs) 1) previous-state))))) ;; Every disjoint owner is proved against the same
;; committed coordinates. Its enclosing properties
;; need that baseline even in a multi-owner patch.
previous-state))))
proofs) proofs)
(list (list
(ebox-surface--owner-patch-candidate (ebox-surface--owner-patch-candidate
@ -4411,6 +4603,15 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(ebox-surface--materialized-fragment-ledger previous-state)))) (ebox-surface--materialized-fragment-ledger previous-state))))
(when (and candidates (cl-every #'identity candidates) (when (and candidates (cl-every #'identity candidates)
(or (not retained-properties-p) retained-fragments)) (or (not retained-properties-p) retained-fragments))
;; Rebuilding any owner reparses the combined replacements. Exact
;; property copies need their ledger baseline on that path; batches
;; that retain the ledger do not put private carriers in output text.
(unless retained-properties-p
(dolist (candidate candidates)
(when (plist-get candidate :retained-properties-p)
(ebox-surface--retain-slot-paint-origins
buffer previous-state (plist-get candidate :old-spans)
(plist-get (plist-get candidate :details) :lines)))))
(with-current-buffer buffer (with-current-buffer buffer
(save-restriction (save-restriction
(widen) (widen)
@ -4889,14 +5090,86 @@ causes the caller to use the ordinary full projection."
(mapcar (lambda (fragment) (plist-get fragment :text)) (mapcar (lambda (fragment) (plist-get fragment :text))
fragments))))))))) fragments)))))))))
(defun ebox-surface--nested-scroll-owned-lines (buffer spans rendered region)
"Select RENDERED's exact chrome-aligned rows owned by BUFFER SPANS and REGION.
Row composition may retain an upper border in its owner spans while Column
composition leaves both border rows outside. Match explicit vertical roles
instead of choosing which edge to trim from a numeric line-count difference."
(let ((lines (ebox-string-lines rendered)) signatures matches)
(cl-labels
((signature (text)
(cl-loop for role in '(mt bt pt pb bb mb)
for property = (alist-get role ebox-region-types)
when (text-property-any 0 (length text) property region text)
collect role)))
(with-current-buffer buffer
(setq signatures
(mapcar (lambda (span)
(signature (buffer-substring (car span) (cdr span))))
spans)))
(let ((rendered-signatures (mapcar #'signature lines))
(count (length spans)))
(cl-loop for start from 0 to (- (length lines) count)
when (equal signatures (cl-subseq rendered-signatures start (+ start count)))
do (push start matches))
(when (= (length matches) 1)
(ebox-lines-join (cl-subseq lines (car matches) (+ (car matches) count))))))))
(defun ebox-surface--nested-scroll-patch-candidate (buffer previous-state state proof)
"Return PROOF's cached nested scroll replacement without rendering siblings.
BUFFER and PREVIOUS-STATE supply the committed physical slots. STATE owns an
isolated scroll Box. Cached content is reshaped only through its wrapper;
changing child roles must satisfy the same exact external footprint proof as
a local composed host."
(let* ((region (plist-get state :scroll-patch-region-id))
(table (plist-get state :scroll-state-table))
(scroll (gethash region table))
(box (plist-get scroll :box))
(owner (plist-get proof :owner-id))
(parent (ebox-runtime-index-get owner (plist-get state :parent-table)))
(valid t))
(while (and valid parent)
(when (ebox-get (ebox-runtime-index-get parent (plist-get state :node-table))
:surface-properties)
(setq valid nil))
(setq parent (ebox-runtime-index-get parent (plist-get state :parent-table))))
(when (and valid box
(ebox--surface-nested-scroll-cached-p scroll 0)
(ebox-surface--unchanged-enclosing-effects-p previous-state state owner))
(let ((ebox--scroll-window-cached-state scroll)
(ebox--scroll-window-skip-state-rebuild-p
(not (plist-get proof :scroll-content-coordinate-p))))
(prog1 (ebox-surface--range-patch-candidate buffer state proof)
;; Slot sizing renders a temporary copy. Future prefix producers
;; and imperative updates must keep the canonical candidate Box.
(puthash region box (plist-get state :region-box-table))
(puthash region scroll table))))))
(defun ebox-surface--nested-scroll-full-overrides (buffer root state-overrides)
"Return private ROOT and overrides after a nested scroll slot proof misses.
BUFFER's candidate scroll caches rebind to the copied tree before any full
renderer may mutate geometry. Published siblings stay untouched on rollback."
(let* ((copy (ebox-tree-copy-node-structure root))
(overrides (copy-sequence state-overrides))
(index (ebox--runtime-index copy t (plist-get overrides :source-index)))
(ebox--scroll-global-state (plist-get overrides :scroll-state-table))
(table (ebox-incremental--candidate-scroll-state-table
buffer overrides index (plist-get index :region-box-table))))
(setq overrides (ebox--render-state-install-index overrides index))
(plist-put overrides :scroll-state-table table)
(plist-put overrides :runtime-index-prepared-p t)
(plist-put overrides :source-path-copied-p nil)
(cl-remf overrides :nested-scroll-patch-proof)
(list copy overrides)))
(defun ebox-surface--scroll-patch-output (_buffer state &optional complete-lines-only-p) (defun ebox-surface--scroll-patch-output (_buffer state &optional complete-lines-only-p)
"Return a staged visible scroll window without rerunning layout when safe. "Return a staged visible scroll window without rerunning layout when safe.
Chrome-free top-aligned root scroll boxes already store fully propertized Chrome-free top-aligned root scroll boxes already store fully propertized
rendered lines. Join only the visible slice and add the scroll marker; any rendered lines. Join only the visible slice and add the scroll marker; any
box with wrapper chrome falls back to the retained wrapper renderer. box with wrapper chrome falls back to the retained wrapper renderer.
When COMPLETE-LINES-ONLY-P is non-nil, return only proven complete cached When COMPLETE-LINES-ONLY-P is non-nil, return only proven complete cached
physical lines, with private pixel-normalized copies where needed. Decline physical lines or a complete cached root wrapper, with private pixel-normalized
wrapper rendering so a caller may keep its ordinary fractional-origin guard." copies where needed. Detached owners keep their fractional-origin guard."
(let* ((region-id (plist-get state :scroll-patch-region-id)) (let* ((region-id (plist-get state :scroll-patch-region-id))
(scroll-table (plist-get state :scroll-state-table)) (scroll-table (plist-get state :scroll-state-table))
(scroll-state (and region-id scroll-table (scroll-state (and region-id scroll-table
@ -4961,11 +5234,31 @@ wrapper rendering so a caller may keep its ordinary fractional-origin guard."
(list 'ebox-scroll-window region-id) (list 'ebox-scroll-window region-id)
output)) output))
output) output)
(unless complete-lines-only-p (let ((root-wrapper-p
(let ((ebox--scroll-window-cached-state scroll-state) (and (or (plist-get state :layered-p) (not chrome-free-p))
(ebox--scroll-window-skip-state-rebuild-p (ebox-layer-cached-root-scroll-p root scroll-state))))
(plist-get scroll-state :content-lines-complete-p))) ;; Ordinary chrome-free roots first rebuild physical row metadata
(ebox-surface--render-candidate-node state root)))))))) ;; after content-only materialization. Retaining that raw cache
;; here would prevent later scrolls from using direct row slices.
(when (or root-wrapper-p (not complete-lines-only-p))
(let* ((ebox--scroll-window-cached-state scroll-state)
(ebox--scroll-window-skip-state-rebuild-p
(or (plist-get scroll-state :content-lines-complete-p)
root-wrapper-p))
;; This wrapper owns the entire physical line origin.
(ebox-surface--integral-local-output-required-p
(and (not root-wrapper-p)
ebox-surface--integral-local-output-required-p))
(output (ebox-surface--render-candidate-node state root)))
(when root-wrapper-p
(plist-put state :fractional-pixel-output-p
(ebox--fractional-pixel-spaces-p output))
(when (plist-get state :fractional-pixel-output-p)
(let ((ebox--render-owned-text-values
(plist-get state :render-owned-text-values)))
(setq output (ebox--quantize-pixel-spaces output))
(ebox--record-render-output-provenance output))))
output))))))))
(defun ebox-surface--native-scroll-full-output (state) (defun ebox-surface--native-scroll-full-output (state)
"Return the complete propertized root scroll output for native scrolling." "Return the complete propertized root scroll output for native scrolling."
@ -5552,18 +5845,15 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(ebox-surface--owned-ranges-from-runs (ebox-surface--owned-ranges-from-runs
context leaf state node-objects region-objects attach-p context leaf state node-objects region-objects attach-p
(lambda (visit) (lambda (visit)
(let ((offset 0)) (unless (ebox-surface--map-scroll-fragments
(dolist (line lines) lines
(let ((template (lambda (fragment offset _line-index)
(or (ebox-surface--scroll-line-fragment-template line) (funcall visit
(error "Ebox retained scroll line lost fragment ownership")))) (plist-get fragment :role-ids)
(dolist (fragment template) (+ offset (plist-get fragment :start))
(funcall visit (+ offset (plist-get fragment :end))
(plist-get fragment :role-ids) (plist-get fragment :layout-region-ids))))
(+ offset (plist-get fragment :start)) (error "Ebox retained scroll line lost fragment ownership")))))
(+ offset (plist-get fragment :end))
(plist-get fragment :layout-region-ids)))
(setq offset (+ offset (length line) 1))))))))
(defun ebox-surface--native-owned-ranges (defun ebox-surface--native-owned-ranges
(context leaf template state node-objects region-objects attach-p) (context leaf template state node-objects region-objects attach-p)
@ -5733,6 +6023,7 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
'(span-patch owner-scoped '(span-patch owner-scoped
mixed-owner-reflow)) mixed-owner-reflow))
(not formatting-reflow-p) (not formatting-reflow-p)
(not (plist-get state :layer-recompose-p))
(plist-get state :previous-surface-owned-ranges) (plist-get state :previous-surface-owned-ranges)
(plist-get state :content-coordinate-patches) (plist-get state :content-coordinate-patches)
(ebox-surface--rebase-owned-ranges (ebox-surface--rebase-owned-ranges
@ -6334,6 +6625,14 @@ published runtime and must be consumed without clearing those shared nodes."
:key ebox-surface--root-key :kind 'ebox/string :key ebox-surface--root-key :kind 'ebox/string
:text source :capability 'content))) :text source :capability 'content)))
(let* ((scroll-patch-p (eq projection-kind 'scroll-patch)) (let* ((scroll-patch-p (eq projection-kind 'scroll-patch))
(scroll-input-stable-p
(and scroll-patch-p previous-state preserve-identities-p
(plist-get state-overrides :scroll-state-transaction)
(plist-get state-overrides :runtime-index-prepared-p)
(equal (plist-get source :node-id)
(plist-get (plist-get previous-state :root-node) :node-id))
(equal (plist-get state-overrides :display-signature)
(plist-get previous-state :display-signature))))
(initial-native-p (initial-native-p
(and (null previous-state) (and (null previous-state)
(null projection-kind) (null projection-kind)
@ -6355,7 +6654,7 @@ published runtime and must be consumed without clearing those shared nodes."
(setq fast nil)) (setq fast nil))
fast))) fast)))
(stylesheet-active-p (stylesheet-active-p
(if scroll-fast-p (if scroll-input-stable-p
(plist-get previous-state :cascade-active-p) (plist-get previous-state :cascade-active-p)
(ebox-style-cascade-active-p))) (ebox-style-cascade-active-p)))
(retained-runtime-source-p (retained-runtime-source-p
@ -6438,20 +6737,20 @@ published runtime and must be consumed without clearing those shared nodes."
(equal (plist-get entry :changed-keys) '(:content))) (equal (plist-get entry :changed-keys) '(:content)))
dirty-set))))) dirty-set)))))
(inline-inheritance-required-p (inline-inheritance-required-p
(unless (or scroll-fast-p retained-runtime-source-p (unless (or scroll-input-stable-p retained-runtime-source-p
retained-inline-styles-p) retained-inline-styles-p)
(ebox-surface--inline-inheritance-required-p (ebox-surface--inline-inheritance-required-p
root source-index))) root source-index)))
(author-style-pending-p (author-style-pending-p
(> (ebox-tree-author-style-count root) 0)) (> (ebox-tree-author-style-count root) 0))
(cascade-required-p (cascade-required-p
(if (or scroll-fast-p retained-runtime-source-p (if (or scroll-input-stable-p retained-runtime-source-p
retained-inline-styles-p) retained-inline-styles-p)
(plist-get previous-state :cascade-required-p) (plist-get previous-state :cascade-required-p)
(or stylesheet-active-p (or stylesheet-active-p
inline-inheritance-required-p))) inline-inheritance-required-p)))
(style-required-p (style-required-p
(if scroll-fast-p (if scroll-input-stable-p
cascade-required-p cascade-required-p
(or cascade-required-p (or cascade-required-p
author-style-pending-p author-style-pending-p
@ -6493,7 +6792,7 @@ published runtime and must be consumed without clearing those shared nodes."
(and (and
signals signals
(cond (cond
((and scroll-fast-p ((and scroll-input-stable-p
(plist-get previous-state (plist-get previous-state
:viewport-dependent-node-ids-ready) :viewport-dependent-node-ids-ready)
(plist-member (plist-member
@ -6641,13 +6940,22 @@ published runtime and must be consumed without clearing those shared nodes."
(ebox-surface--native-scroll-full-output state)) (ebox-surface--native-scroll-full-output state))
(ebox-surface--scroll-patch-output (ebox-surface--scroll-patch-output
(ebox-surface--signals-buffer signals) state) (ebox-surface--signals-buffer signals) state)
(when-let* ((proof (plist-get state :nested-scroll-patch-proof))
((or (not (plist-get proof :scroll-content-region-id))
(plist-get state :scroll-content-proof))))
(unless (plist-get state :owner-scoped-proofs)
(plist-put state :owner-scoped-proofs (list proof)))
(ebox-surface--span-patch-output
(ebox-surface--signals-buffer signals)
previous-state state))
;; A nested scroll already owns a ;; A nested scroll already owns a
;; complete candidate projection. ;; complete candidate projection.
;; Preserve its ordinary renderer ;; Preserve its ordinary renderer
;; fallback without recascading the ;; fallback without recascading the
;; retained source. The raw-output ;; retained source. The raw-output
;; guard still rejects fractions. ;; guard still rejects fractions.
(unless (plist-get state :native-scroll-materialize-p) (unless (or (plist-get state :native-scroll-materialize-p)
(plist-get state :nested-scroll-patch-proof))
(ebox-surface--render-candidate state)))) (ebox-surface--render-candidate state))))
('formatting-context-reflow ('formatting-context-reflow
(ebox-surface--formatting-context-reflow-output (ebox-surface--formatting-context-reflow-output
@ -6664,6 +6972,12 @@ published runtime and must be consumed without clearing those shared nodes."
;; A declined output may already have staged coordinates, ;; A declined output may already have staged coordinates,
;; fragments or paint layers. Rebuild from the original ;; fragments or paint layers. Rebuild from the original
;; candidate inputs before producing ordinary full output. ;; candidate inputs before producing ordinary full output.
(when (plist-get state-overrides :nested-scroll-patch-proof)
(pcase-let ((`(,private-root ,private-overrides)
(ebox-surface--nested-scroll-full-overrides
(ebox-surface--signals-buffer signals)
root state-overrides)))
(setq root private-root state-overrides private-overrides)))
;; Range deltas already projected their complete object ;; Range deltas already projected their complete object
;; map, including newly registered keys in this context. ;; map, including newly registered keys in this context.
(unless (and (eq projection-kind 'mixed-owner-reflow) (unless (and (eq projection-kind 'mixed-owner-reflow)
@ -6736,6 +7050,7 @@ published runtime and must be consumed without clearing those shared nodes."
(cl-remf state :native-removed-node-ids) (cl-remf state :native-removed-node-ids)
(cl-remf state :computed-participation-validated-p) (cl-remf state :computed-participation-validated-p)
(cl-remf state :candidate-subject-table) (cl-remf state :candidate-subject-table)
(cl-remf state :nested-scroll-patch-proof)
result))))))) result)))))))
(defun ebox-surface-producer (defun ebox-surface-producer

244
ebox.el
View File

@ -91,6 +91,44 @@
buffer 'batch buffer 'batch
(lambda () (ebox-incremental--flush buffer)))) (lambda () (ebox-incremental--flush buffer))))
;;;###autoload
(defun ebox-call-with-update-batch (callback &optional buffer)
"Call CALLBACK in BUFFER, coalescing its region updates into one publication.
BUFFER defaults to the current buffer. CALLBACK receives no arguments and its
return value is returned unchanged. Flush after CALLBACK returns normally;
an empty or unchanged batch does not publish or replace the last update report.
When BUFFER already has an active batch, join it without flushing. Errors,
quits and other nonlocal exits discard only this call's queued changes and
preserve the outer batch. A failed owned flush discards its unpublished batch;
the TP publication boundary preserves the previously committed generation.
This scope covers queued `ebox-region-update' calls for BUFFER, not arbitrary
side effects in CALLBACK. Leave explicit begin/flush lifecycle calls outside
CALLBACK; the existing explicit API retains its separate retry semantics."
(let* ((buffer (or buffer (current-buffer)))
(previous (ebox-incremental--batch-state buffer))
(outer-batch (ebox-incremental-batching-p buffer))
completed)
(with-current-buffer buffer
(unwind-protect
(progn
(if outer-batch
;; Queuing replaces candidate roots/source indexes and appends
;; pending entries, so copying the plist isolates this scope.
(puthash buffer (copy-sequence previous)
ebox-incremental--batch-table)
(ebox-incremental-begin-batch buffer))
(prog1 (funcall callback)
(when (and (not outer-batch)
(> (ebox-incremental-pending-count buffer) 0))
(ebox-incremental-flush buffer))
(setq completed t)))
(unless (and outer-batch completed)
(if previous
(puthash buffer previous ebox-incremental--batch-table)
(remhash buffer ebox-incremental--batch-table)))))))
(declare-function ebox--buffer-update-report "ebox-incremental" (buffer)) (declare-function ebox--buffer-update-report "ebox-incremental" (buffer))
(declare-function ebox--set-buffer-update-report (declare-function ebox--set-buffer-update-report
"ebox-incremental" (buffer report)) "ebox-incremental" (buffer report))
@ -1089,6 +1127,7 @@ content back to their own source model."
:region-line-span-index-deferred :region-line-span-index-deferred
:rendered-region-line-span-index :rendered-region-line-span-index
:rendered-region-line-span-index-deferred :rendered-region-line-span-index-deferred
:rendered-content-line-cells
:region-line-span-hints :region-line-span-hints
:lazy-scroll-prefix-dirty :lazy-scroll-prefix-dirty
:lazy-scroll-window-refresh-required :lazy-scroll-window-refresh-required
@ -1268,6 +1307,33 @@ remain transaction-local."
(when had-state (when had-state
(ebox--record-render-cache-scroll-region region-id)))) (ebox--record-render-cache-scroll-region region-id))))
(defun ebox--scroll-rendered-line-cells-current-p (cells lines)
"Return non-nil when positional CELLS still describe rendered LINES.
Rendered list spines are append-only between installations. Equal-length
prefix rewrites replace cars, so retained cons handles see current row values;
an appended tail or replacement head invalidates the positional index."
(and (vectorp cells) (> (length cells) 0)
(eq (aref cells 0) lines)
(null (cdr (aref cells (1- (length cells)))))))
(defun ebox--scroll-rendered-line-cells (state lines)
"Return STATE's immutable positional cons-cell index for complete LINES.
Only a complete cache is indexed, avoiding full-prefix rebuilds during lazy
extension. Indexes may be shared by shallow candidate state copies; replacement
or extension creates a new vector without changing earlier state or row data."
(when (or (plist-get state :content-lines-complete-p)
(and (not (plist-get state :render-content-prefix))
(not (plist-get state :materialize-content-lines))))
(let ((cells (plist-get state :rendered-content-line-cells)))
(unless (ebox--scroll-rendered-line-cells-current-p cells lines)
(setq cells (make-vector (length lines) nil))
(let ((tail lines))
(dotimes (index (length cells))
(aset cells index tail)
(setq tail (cdr tail))))
(plist-put state :rendered-content-line-cells cells))
cells)))
(defun ebox--scroll-state-rendered-visible-window (state) (defun ebox--scroll-state-rendered-visible-window (state)
"Return STATE's current rendered visible slice, including text properties." "Return STATE's current rendered visible slice, including text properties."
(let* ((content-height (plist-get state :content-height)) (let* ((content-height (plist-get state :content-height))
@ -1282,10 +1348,16 @@ remain transaction-local."
(list offset native-lines)) (list offset native-lines))
((and (integerp content-height) (>= content-height 0)) ((and (integerp content-height) (>= content-height 0))
(when-let* ((lines (plist-get state :rendered-content-lines))) (when-let* ((lines (plist-get state :rendered-content-lines)))
(let* ((max-offset (max 0 (- (length lines) content-height))) (let* ((cells (ebox--scroll-rendered-line-cells state lines))
(count (if cells (length cells) (length lines)))
(max-offset (max 0 (- count content-height)))
(offset (max 0 (min max-offset offset))) (offset (max 0 (min max-offset offset)))
(end (min (+ offset content-height) (length lines)))) (end (min (+ offset content-height) count)))
(list offset (seq-subseq lines offset end)))))))) (list offset
(if cells
(when (< offset end)
(seq-subseq (aref cells offset) 0 (- end offset)))
(seq-subseq lines offset end)))))))))
(defun ebox--scroll-state-retained-window-ready-p (state) (defun ebox--scroll-state-retained-window-ready-p (state)
"Return non-nil when STATE can publish its cached visible window directly. "Return non-nil when STATE can publish its cached visible window directly.
@ -2047,6 +2119,9 @@ fall back to the complete role scanner."
(defun ebox--scroll-state-set-lines (defun ebox--scroll-state-set-lines
(state content-lines &optional rendered-lines) (state content-lines &optional rendered-lines)
"Return STATE with CONTENT-LINES and derived span indexes installed." "Return STATE with CONTENT-LINES and derived span indexes installed."
(unless (ebox--scroll-rendered-line-cells-current-p
(plist-get state :rendered-content-line-cells) rendered-lines)
(setq state (plist-put state :rendered-content-line-cells nil)))
(let ((old-lines (plist-get state :content-lines))) (let ((old-lines (plist-get state :content-lines)))
(setq state (setq state
(plist-put (plist-put
@ -3061,23 +3136,117 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(ebox--scroll-state-ensure-prefix-lines (ebox--scroll-state-ensure-prefix-lines
region-id state required-lines t)))) region-id state required-lines t))))
(defun ebox--surface-scroll-runtime (buffer region-id) (defun ebox--surface-scroll-cached-intent-p (scroll delta)
"Return isolated scroll candidate runtime for BUFFER and REGION-ID." "Return non-nil when SCROLL can consume DELTA without any producer work."
(and (not (plist-get scroll :lazy-scroll-prefix-dirty))
(not (plist-get scroll :lazy-scroll-window-refresh-required))
(not (plist-get scroll :native-reflow-target-prefix-p))
(not (and (plist-get scroll :native-reflow-materialize-p)
(plist-get scroll :materialize-content-lines)))
(or (plist-get scroll :content-lines-complete-p)
(and (not (plist-get scroll :render-content-prefix))
(not (plist-get scroll :materialize-content-lines)))
(and (integerp delta)
(<= (+ (max 0 (+ (or (plist-get scroll :scroll-offset) 0) delta))
(or (plist-get scroll :content-height) 0))
(length (plist-get scroll :content-lines)))))))
(defun ebox--surface-nested-scroll-cached-p (scroll &optional delta)
"Return non-nil when SCROLL can move DELTA without running a producer.
Complete caches support the ordinary wrapper proof. An incomplete prefix
must cover the exact requested chrome-free window before nodes can be shared."
(or (plist-get scroll :content-lines-complete-p)
(and (not (plist-get scroll :render-content-prefix))
(not (plist-get scroll :materialize-content-lines)))
(and (integerp delta)
(not (plist-get scroll :native-reflow-materialize-p))
(not (plist-get scroll :native-reflow-target-prefix-p))
(let* ((offset (max 0 (+ (or (plist-get scroll :scroll-offset) 0) delta)))
(height (plist-get scroll :content-height))
(candidate (plist-put (copy-sequence scroll) :scroll-offset offset))
(visible (ebox--scroll-state-retained-window-ready-p candidate)))
(and (integerp height) visible (= (car visible) offset)
(= (length (cadr visible)) height)
(<= (+ offset height) (length (plist-get scroll :content-lines))))))))
(defun ebox--surface-nested-scroll-proof
(buffer state region-id owner-id &optional delta isolated-producer-p)
"Return a retained allocation proof for BUFFER's nested scroll REGION-ID.
OWNER-ID names its Box in STATE. Complete cached content keeps intrinsic size
unchanged; the publisher still validates every replacement slot and enclosing
effect. DELTA additionally permits a covered incomplete prefix.
ISOLATED-PRODUCER-P permits a clean prefix miss after the caller isolates the
owner subtree and its producer. One enclosing root cache has a separate
coordinate proof; deeper scroll chains, layers and fractions keep fallback."
(let* ((root (plist-get state :root-node))
(table (plist-get state :scroll-state-table))
(scroll (and table (gethash region-id table)))
(box (and scroll (plist-get scroll :box)))
enclosing-region)
(when (and owner-id root box
(not (equal owner-id (plist-get root :node-id)))
(cl-every
(lambda (other-region)
(or (equal other-region region-id)
(when-let* ((other-owner
(ebox--buffer-region-render-owner-node-id
buffer other-region)))
(cond
((equal other-owner (plist-get root :node-id))
(setq enclosing-region other-region))
(t
(not (or (ebox--runtime-ancestor-id-p buffer other-owner owner-id)
(ebox--runtime-ancestor-id-p buffer owner-id other-owner))))))))
(plist-get state :scroll-region-ids))
(or (ebox--surface-nested-scroll-cached-p scroll delta)
(and isolated-producer-p (integerp delta) (> delta 0)
(plist-get scroll :render-content-prefix)
(not (plist-get scroll :native-reflow-materialize-p))
(not (plist-get scroll :native-reflow-target-prefix-p))))
(plist-get scroll :content-lines)
(not (plist-get scroll :lazy-scroll-prefix-dirty))
(not (plist-get scroll :lazy-scroll-window-refresh-required))
(not (plist-get state :fractional-pixel-output-p))
(not (if (plist-member state :layered-p)
(plist-get state :layered-p)
(ebox-layer-active-p root)))
(equal (plist-get state :display-signature)
(ebox--current-display-signature)))
(if enclosing-region
;; The incremental preparation validates the owner in its enclosing
;; root cache, including owners currently outside the mounted window.
(list :owner-id owner-id :nested-scroll-patch-p t
:scroll-content-region-id enclosing-region)
(when-let* ((snapshot (ebox--ensure-layout-snapshot-details buffer owner-id))
((plist-get snapshot :buffer-spans))
(width (plist-get
(plist-get snapshot :external-footprint-signature)
:max-line-pixel-width)))
(list :owner-id owner-id :snapshot snapshot :allocated-width width
:nested-scroll-patch-p t))))))
(defun ebox--surface-scroll-runtime (buffer region-id &optional delta)
"Return isolated scroll candidate runtime for BUFFER and REGION-ID.
DELTA proves cached hits before sharing children or continuation closures.
A miss owns its subtree, indexes, cache lines and producers before staging."
(let* ((old-state (ebox--buffer-render-state buffer)) (let* ((old-state (ebox--buffer-render-state buffer))
(old-root (plist-get old-state :root-node)) (old-root (plist-get old-state :root-node))
(old-scroll-state (gethash region-id (plist-get old-state :scroll-state-table)))
(cached-intent-p (ebox--surface-scroll-cached-intent-p old-scroll-state delta))
(owner-id (ebox--buffer-region-render-owner-node-id (owner-id (ebox--buffer-region-render-owner-node-id
buffer region-id)) buffer region-id))
(nested-proof (ebox--surface-nested-scroll-proof
buffer old-state region-id owner-id delta (not cached-intent-p)))
;; Scrolling changes one box property and never changes the logical ;; Scrolling changes one box property and never changes the logical
;; tree. Copy only that owner path when it is the published root; ;; tree. A nested owner additionally keeps a retained allocation
;; nested scroll owners retain the old conservative isolated-copy ;; certificate; its cached wrapper never renders shared descendants. The
;; path until their ancestor publication contract is proven. The
;; previous implementation copied and indexed the complete gallery ;; previous implementation copied and indexed the complete gallery
;; for every wheel event, which made a 512-node grid visibly stall. ;; for every wheel event, which made a 512-node grid visibly stall.
(path-root (path-root
(and owner-id old-root (and owner-id old-root
(equal owner-id (plist-get old-root :node-id)) (or (equal owner-id (plist-get old-root :node-id)) nested-proof)
(ebox-incremental-surface-region-candidate-root (ebox-incremental-surface-region-candidate-root
buffer region-id))) buffer region-id (not cached-intent-p))))
(root (or path-root (root (or path-root
(ebox-tree-copy-node-structure old-root))) (ebox-tree-copy-node-structure old-root)))
;; All runtime index keys remain stable for a scroll-only path copy. ;; All runtime index keys remain stable for a scroll-only path copy.
@ -3087,29 +3256,49 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(copy-hash-table (plist-get old-state :region-box-table))) (copy-hash-table (plist-get old-state :region-box-table)))
(candidate-box (ebox--root-region-box root region-id)) (candidate-box (ebox--root-region-box root region-id))
(_ (when candidate-box (_ (when candidate-box
(puthash region-id candidate-box region-box-table))) (puthash region-id candidate-box region-box-table)
(when-let* ((enclosing (plist-get nested-proof :scroll-content-region-id)))
(puthash enclosing root region-box-table))))
(index (index
(list :region-id-set (plist-get old-state :region-id-set) (if cached-intent-p
:region-box-table region-box-table)) (list :region-id-set (plist-get old-state :region-id-set)
:region-box-table region-box-table)
(plist-get (ebox-incremental--candidate-local-index-delta old-state root) :index)))
(_private-registry
(setq region-box-table (plist-get index :region-box-table)))
(scroll-table (scroll-table
(ebox-incremental--candidate-scroll-state-table (let ((ebox--render-string-pixel-width-cache
buffer old-state index region-box-table)) (unless cached-intent-p (make-hash-table :test 'equal)))
(old-scroll-state (ebox--scroll-get-state region-id)) (ebox--render-string-max-pixel-width-cache
(unless cached-intent-p (make-hash-table :test 'eq)))
(ebox--render-recached-source-node-cache
(unless cached-intent-p (make-hash-table :test 'eq))))
(ebox-incremental--candidate-scroll-state-table
buffer old-state index region-box-table)))
(candidate-scroll-state (gethash region-id scroll-table)) (candidate-scroll-state (gethash region-id scroll-table))
(state (copy-sequence old-state))) (state (copy-sequence old-state)))
(dolist (key '(:render-content-prefix :materialize-content-lines)) ;; Even a cache hit publishes a new Box. Keep its rebound continuations:
(when (plist-member old-scroll-state key) ;; a later idle prefix or materializer must never return the retired Box.
(unless cached-intent-p
;; Prefix continuations may extend or rewrite both lists and their
;; indexes in place. A new closure must never receive published spines.
(let ((ebox--defer-scroll-content-index t))
(plist-put candidate-scroll-state :region-line-bounds-index nil)
(setq candidate-scroll-state (setq candidate-scroll-state
(plist-put candidate-scroll-state key (ebox--scroll-state-set-lines
(plist-get old-scroll-state key))))) candidate-scroll-state
(mapcar #'copy-sequence (plist-get old-scroll-state :content-lines))
(mapcar #'copy-sequence (plist-get old-scroll-state :rendered-content-lines))))))
(puthash region-id candidate-scroll-state scroll-table) (puthash region-id candidate-scroll-state scroll-table)
(setq state (ebox--render-state-install-index state index)) (setq state (ebox--render-state-install-index state index))
(plist-put state :root-node root) (plist-put state :root-node root)
(plist-put state :scroll-state-table scroll-table) (plist-put state :scroll-state-table scroll-table)
(unless cached-intent-p
(ebox-incremental--isolate-candidate-caches state))
(unless (gethash region-id scroll-table) (unless (gethash region-id scroll-table)
(error "Ebox scroll region has no candidate state: %S" region-id)) (error "Ebox scroll region has no candidate state: %S" region-id))
(list :root root :state state :scroll-table scroll-table (list :root root :state state :scroll-table scroll-table
:base-state old-state))) :base-state old-state :nested-scroll-patch-proof nested-proof)))
(defun ebox--surface-scroll-target (region-id state delta prefix-budget-lines) (defun ebox--surface-scroll-target (region-id state delta prefix-budget-lines)
"Return REGION-ID STATE staged by DELTA within PREFIX-BUDGET-LINES." "Return REGION-ID STATE staged by DELTA within PREFIX-BUDGET-LINES."
@ -3201,9 +3390,9 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(error "Ebox runtime changed during scroll update notification")) (error "Ebox runtime changed during scroll update notification"))
(let* ((staged-scroll-state (copy-sequence state)) (let* ((staged-scroll-state (copy-sequence state))
(scroll-patch-fast-p (scroll-patch-fast-p
(and (ebox--scroll-state-retained-window-ready-p (or (and (ebox--scroll-state-retained-window-ready-p
staged-scroll-state) staged-scroll-state) t)
t)) (and (plist-get runtime :nested-scroll-patch-proof) t)))
(commit-input (commit-input
(let ((ebox--scroll-global-state table)) (let ((ebox--scroll-global-state table))
(ebox-incremental-prepare-scoped-commit (ebox-incremental-prepare-scoped-commit
@ -3232,6 +3421,9 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(list owner-id))) (list owner-id)))
(state-overrides (state-overrides
(let ((value (plist-get commit-input :state-overrides))) (let ((value (plist-get commit-input :state-overrides)))
(when (plist-get runtime :nested-scroll-patch-proof)
(setq value (plist-put value :nested-scroll-patch-proof
(plist-get runtime :nested-scroll-patch-proof))))
(setq value (setq value
(plist-put (plist-put
value :preserve-scroll-producer-region-ids value :preserve-scroll-producer-region-ids
@ -3270,6 +3462,7 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(table (plist-get overrides :scroll-state-table)) (table (plist-get overrides :scroll-state-table))
(state (gethash region-id table))) (state (gethash region-id table)))
(dolist (key '(:content-lines :rendered-content-lines (dolist (key '(:content-lines :rendered-content-lines
:rendered-content-line-cells
:content-lines-complete-p :render-content-prefix :content-lines-complete-p :render-content-prefix
:materialize-content-lines :content-region-id-set :materialize-content-lines :content-region-id-set
:region-line-bounds-index :region-line-span-index :region-line-bounds-index :region-line-span-index
@ -3290,11 +3483,12 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(defun ebox--surface-scroll-region-by (defun ebox--surface-scroll-region-by
(buffer region-id delta prefix-budget-lines) (buffer region-id delta prefix-budget-lines)
"Scroll mounted BUFFER REGION-ID by DELTA within PREFIX-BUDGET-LINES." "Scroll mounted BUFFER REGION-ID by DELTA within PREFIX-BUDGET-LINES."
(let* ((runtime (ebox--surface-scroll-runtime buffer region-id)) (let* ((runtime (ebox--surface-scroll-runtime buffer region-id delta))
(table (plist-get runtime :scroll-table)) (table (plist-get runtime :scroll-table))
(candidate-state (plist-get runtime :state)) (candidate-state (plist-get runtime :state))
(transition (transition
(let ((ebox--scroll-global-state table) (let ((ebox--scroll-global-state table)
(ebox--region-box-table (plist-get candidate-state :region-box-table))
(ebox--scroll-idle-prefetch-timers (ebox--scroll-idle-prefetch-timers
(make-hash-table :test 'equal)) (make-hash-table :test 'equal))
(ebox--smooth-scroll-state-table (ebox--smooth-scroll-state-table

View File

@ -455,7 +455,7 @@
(should (zerop root-renders))))))) (should (zerop root-renders)))))))
(ert-deftest ebox-commit-inherited-text-exclusions-render-current-properties () (ert-deftest ebox-commit-inherited-text-exclusions-render-current-properties ()
"Property, extent, display and identity changes retain the exact fallback." "Unsafe property changes fall back; proven source rebinding keeps current facts."
(dolist (change '(property char-length display identity opaque-callback)) (dolist (change '(property char-length display identity opaque-callback))
(ert-info ((format "inherited text exclusion: %S" change)) (ert-info ((format "inherited text exclusion: %S" change))
(with-temp-buffer (with-temp-buffer
@ -492,7 +492,12 @@
(when (eq change 'identity) (when (eq change 'identity)
(should-not (ebox--host-ref-node (current-buffer) 'counter)) (should-not (ebox--host-ref-node (current-buffer) 'counter))
(should (ebox--host-ref-node (current-buffer) 'new-counter))) (should (ebox--host-ref-node (current-buffer) 'new-counter)))
(should (> (cdr result) 0))))))))) ;; A source handle can change while the runtime slot, author key
;; and full rendered properties remain proven. The assertions
;; above also require replacing its public host lookup identity.
(if (eq change 'identity)
(should (zerop (cdr result)))
(should (> (cdr result) 0))))))))))
(ert-deftest ebox-commit-inherited-text-rolls-back-properties-and-retries () (ert-deftest ebox-commit-inherited-text-rolls-back-properties-and-retries ()
"Late publication rejection retains text, metadata and one old generation." "Late publication rejection retains text, metadata and one old generation."
@ -1833,7 +1838,7 @@
(when (buffer-live-p buffer) (kill-buffer buffer))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-commit-expands-scope-for-length-changing-column-content () (ert-deftest ebox-commit-expands-scope-for-length-changing-column-content ()
"A column content growth must publish shifted later styled siblings." "An intrinsically sized column grows with content and later styled siblings."
(let* ((buffer (generate-new-buffer-name " *ebox-commit-column-scope*")) (let* ((buffer (generate-new-buffer-name " *ebox-commit-column-scope*"))
(old-root (old-root
(ebox-test-column (ebox-test-column
@ -1866,7 +1871,8 @@
(with-current-buffer buffer (buffer-string)))) (with-current-buffer buffer (buffer-string))))
(should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :patch-ops) '(owner-rerender))) (should (equal (plist-get report :patch-ops) '(owner-rerender)))
(should-not (plist-get report :tp-scope-fallback))) (should-not (plist-get report :tp-scope-fallback))
(ebox-commit-test--assert-allocated-output (get-buffer buffer) new-root))
(when (buffer-live-p buffer) (when (buffer-live-p buffer)
(kill-buffer buffer))))) (kill-buffer buffer)))))
@ -2314,6 +2320,8 @@ remain retained identities."
;; publishes one scoped transaction, with one exact text operation ;; publishes one scoped transaction, with one exact text operation
;; per changed slot instead of replacing the unchanged gap. ;; per changed slot instead of replacing the unchanged gap.
(should (= 2 (plist-get report :tp-text-operations))) (should (= 2 (plist-get report :tp-text-operations)))
(with-current-buffer buffer
(ebox-commit-test--assert-full-render-equivalent))
(should (string-match-p "L" (should (string-match-p "L"
(ebox-commit-test--buffer-string buffer))) (ebox-commit-test--buffer-string buffer)))
(setq fresh (setq fresh
@ -2383,10 +2391,61 @@ remain retained identities."
(should (memq (plist-get report :projection-kind) (should (memq (plist-get report :projection-kind)
'(span-patch owner-scoped))) '(span-patch owner-scoped)))
(should-not (plist-get report :tp-full-root)) (should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))))) (should-not (plist-get report :tp-scope-fallback))
(with-current-buffer buffer
(ebox-commit-test--assert-full-render-equivalent)))))
(when (buffer-live-p buffer) (when (buffer-live-p buffer)
(kill-buffer buffer))))))) (kill-buffer buffer)))))))
(ert-deftest ebox-commit-variable-grid-retains-ancestor-and-caller-properties ()
"Anonymous Text replacements keep proven properties and new opaque values."
(let* ((callback (lambda () 'original))
(replacement-callback (lambda () 'replacement))
(payload (make-symbol "caller-payload"))
(properties (list 'help-echo callback 'caller-payload payload)))
(cl-labels
((child (key value action)
(ebox-test-box
:key key :source-identity key
(ebox-test-text (propertize value 'face '(:weight bold)
'action action)))))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-test-grid
:key 'grid :width '(80) :grid-template-columns '((36) (36))
:column-gap '(8) :bgcolor "#102030" :surface-properties properties
(child 'left "left-old" callback)
(child 'right "right-old" callback)))
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-host-ref candidate 'left (child 'left "L" callback))
(ebox-candidate-replace-host-ref candidate 'right (child 'right "R" callback))
(let* ((observed
(ebox-commit-test--count-root-renders
(lambda () (ebox-commit (current-buffer) candidate))))
(report (car observed)))
(should (zerop (cdr observed)))
(should (memq (plist-get report :projection-kind)
'(span-patch owner-scoped)))))
(ebox-commit-test--assert-full-render-equivalent)
(dolist (value '("L" "R"))
(goto-char (point-min))
(search-forward value)
(should (eq callback (get-text-property (1- (point)) 'action)))
(should (eq callback (get-text-property (1- (point)) 'help-echo)))
(should (eq payload (get-text-property (1- (point)) 'caller-payload))))
;; A new opaque value must survive any declined local property proof.
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-host-ref
candidate 'left (child 'left "NEW" replacement-callback))
(ebox-commit (current-buffer) candidate))
(ebox-commit-test--assert-full-render-equivalent)
(goto-char (point-min))
(search-forward "NEW")
(should (eq replacement-callback
(get-text-property (1- (point)) 'action)))
(should (eq callback (get-text-property (1- (point)) 'help-echo)))))))
(ert-deftest ebox-commit-updates-selector-type-counts () (ert-deftest ebox-commit-updates-selector-type-counts ()
"A structural candidate should publish exact author selector-type counts." "A structural candidate should publish exact author selector-type counts."
(let* ((buffer (let* ((buffer
@ -2896,7 +2955,7 @@ Normalize only buffer-local region identifiers; retain every text property."
buffer (ebox-commit-test--allocated-single-root content color nil sibling-content))) buffer (ebox-commit-test--allocated-single-root content color nil sibling-content)))
(defun ebox-commit-test--allocated-single-round-trip (paint-p) (defun ebox-commit-test--allocated-single-round-trip (paint-p)
"One longer/shorter row promotes its allocated owner, with disjoint paint." "One longer/shorter row retains its smallest proven allocation and scroll."
(ert-info ((format "allocated single with disjoint paint: %S" paint-p)) (ert-info ((format "allocated single with disjoint paint: %S" paint-p))
(with-temp-buffer (with-temp-buffer
(ebox-render-to-buffer (ebox-render-to-buffer

View File

@ -144,6 +144,26 @@
(should (equal-including-properties before source)) (should (equal-including-properties before source))
(should (equal (plist-get (cdr display) :width) '(15.5)))))) (should (equal (plist-get (cdr display) :width) '(15.5))))))
(ert-deftest ebox-composite-slice-canonicalizes-whole-pixel-cuts ()
"Equal cuts share display properties regardless of numeric allocation type."
(ebox-composite-test--with-metrics
(let* ((height (list 20))
(display (list 'space :width '(15.5) :height height))
(source (propertize "abc" 'display display))
(integer-cut (ebox-composite-slice source 2 13))
(float-cut (ebox-composite-slice source 2.0 13.0))
(fractional-cut (ebox-composite-slice source 2.0 13.5)))
(should (equal-including-properties integer-cut float-cut))
(should (equal (plist-get (cdr (get-text-property 0 'display float-cut))
:width)
'(11)))
(should (eq (plist-get (cdr (get-text-property 0 'display float-cut))
:height)
height))
(ebox-composite-test--assert-width fractional-cut 11.5)
(should (eq (get-text-property 0 'display source) display))
(should (equal (plist-get (cdr display) :width) '(15.5))))))
(ert-deftest ebox-composite-slice-never-splits-replacement-runs () (ert-deftest ebox-composite-slice-never-splits-replacement-runs ()
"Shared string and image replacements are kept whole, even across faces." "Shared string and image replacements are kept whole, even across faces."
(ebox-composite-test--with-metrics (ebox-composite-test--with-metrics

View File

@ -367,6 +367,24 @@
"Return first content face in STRING." "Return first content face in STRING."
(ebox-test--face-at-property string 'ebox-content)) (ebox-test--face-at-property string 'ebox-content))
(cl-defmacro ebox-test--with-scroll-producer-counts
((prefix-count &optional materialize-count requested-lines) &body body)
"Run BODY observing source calls across candidate producer rebinding.
Increment PREFIX-COUNT for bounded rendering, optional MATERIALIZE-COUNT for
full rendering, and store the latest bounded limit in REQUESTED-LINES."
(declare (indent 1))
(let ((original (make-symbol "original-scroll-source")))
`(let ((,original (symbol-function 'ebox--render-scroll-window-source)))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (source limit &optional cache)
(if limit
(progn
(cl-incf ,prefix-count)
,@(when requested-lines `((setq ,requested-lines limit))))
,@(when materialize-count `((cl-incf ,materialize-count))))
(funcall ,original source limit cache))))
,@body))))
(defmacro ebox-test--count-full-surface-updates (&rest body) (defmacro ebox-test--count-full-surface-updates (&rest body)
"Run BODY and return the number of full TP surface updates it triggered." "Run BODY and return the number of full TP surface updates it triggered."
(declare (indent 0)) (declare (indent 0))
@ -4999,6 +5017,201 @@ line compositor's complete property output, including empty lines and chrome."
(should (eq (plist-get flush-report :strategy) (should (eq (plist-get flush-report :strategy)
'paint-patch))))))) 'paint-patch)))))))
(ert-deftest ebox-update-batch-coalesces-ordinary-regions-in-target-buffer ()
"A callback should queue every region update and publish once in BUFFER."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer
(ebox-build '(column :width (px 180)
(box :id first "First")
(box :id second "Second")))
(let ((target (current-buffer))
(before (buffer-string))
(revision (ebox-surface-buffer-revision (current-buffer)))
(publisher (symbol-function 'ebox--publish-surface-region-batch))
(result (list 'callback-result))
(publications 0))
(cl-letf (((symbol-function 'ebox--publish-surface-region-batch)
(lambda (&rest arguments)
(cl-incf publications)
(apply publisher arguments))))
(with-temp-buffer
(let ((caller (current-buffer)))
(should
(eq result
(ebox-call-with-update-batch
(lambda ()
(should (eq (current-buffer) target))
(ebox-region-update 'first :content "First changed")
(ebox-region-update 'second :color "#112233")
(ebox-region-update 'first :background-color "#445566")
(ebox-region-update 'second :content "Second changed")
(should (= (ebox-incremental-pending-count target) 4))
(should (= publications 0))
(should (= revision (ebox-surface-buffer-revision target)))
(should (equal-including-properties before (buffer-string)))
result)
target)))
(should (eq caller (current-buffer))))))
(should (= publications 1))
(should (= (1+ revision) (ebox-surface-buffer-revision target)))
(should (= (plist-get (ebox-buffer-update-report target) :batched-count) 4))
(should-not (gethash target ebox-incremental--batch-table))
(should (string-match-p "First changed" (buffer-string)))
(should (string-match-p "Second changed" (buffer-string)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-update-batch-empty-callback-preserves-prior-state ()
"An empty or unchanged callback should not flush or replace prior reports."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer
(ebox-build '(box :id target :color "#112233" "Stable"))
(let ((buffer (current-buffer)))
(ebox-incremental-begin-batch buffer)
(ebox-incremental-flush buffer)
(let ((state (ebox-incremental--batch-state buffer))
(report (ebox-buffer-update-report buffer))
(revision (ebox-surface-buffer-revision buffer)))
(cl-letf (((symbol-function 'ebox-incremental-flush)
(lambda (_) (ert-fail "Empty callback must not flush"))))
(should (eq 'empty (ebox-call-with-update-batch (lambda () 'empty))))
(should-not
(ebox-call-with-update-batch
(lambda () (ebox-region-update 'target :color "#112233")))))
(should (eq state (ebox-incremental--batch-state buffer)))
(should (equal report (ebox-buffer-update-report buffer)))
(should (= revision (ebox-surface-buffer-revision buffer)))))))
(ert-deftest ebox-update-batch-nested-success-flushes-only-outer-callback ()
"Nested callbacks should share one accumulated candidate and publication."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer
(ebox-build '(column :width (px 180)
(box :id first "First")
(box :id second "Second")))
(let ((buffer (current-buffer))
(publisher (symbol-function 'ebox--publish-surface-region-batch))
(publications 0))
(cl-letf (((symbol-function 'ebox--publish-surface-region-batch)
(lambda (&rest arguments)
(cl-incf publications)
(apply publisher arguments))))
(should
(eq 'outer
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'first :content "Outer")
(should
(eq 'inner
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'second :content "Inner")
'inner))))
(should (ebox-incremental-batching-p buffer))
(should (= (ebox-incremental-pending-count buffer) 2))
(should (= publications 0))
(ebox-region-update 'first :color "#112233")
'outer)))))
(should (= publications 1))
(should-not (ebox-incremental-batching-p buffer))
(should (string-match-p "Outer" (buffer-string)))
(should (string-match-p "Inner" (buffer-string)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-update-batch-joins-explicit-batch-without-flushing ()
"A callback inside legacy begin/flush should leave publication to its caller."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer
(ebox-build '(box :id target "Original"))
(let ((buffer (current-buffer))
(before (buffer-string)))
(ebox-incremental-begin-batch buffer)
(ebox-region-update 'target :color "#112233")
(cl-letf (((symbol-function 'ebox-incremental-flush)
(lambda (_) (ert-fail "Joined callback must not flush"))))
(ebox-call-with-update-batch
(lambda () (ebox-region-update 'target :content "Joined"))))
(should (ebox-incremental-batching-p buffer))
(should (= (ebox-incremental-pending-count buffer) 2))
(should (equal-including-properties before (buffer-string)))
(ebox-incremental-flush buffer)
(should (string-match-p "Joined" (buffer-string)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-update-batch-nonlocal-exits-restore-owned-or-outer-state ()
"Errors, quits and throws should discard only the callback's queued changes."
(dolist (outer '(nil t))
(dolist (exit-kind '(error quit throw))
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer
(ebox-build '(column :width (px 180)
(box :id first "First")
(box :id second "Second")))
(let* ((buffer (current-buffer))
(before (buffer-string))
(runtime (ebox--buffer-render-state buffer))
(revision (ebox-surface-buffer-revision buffer)))
(when outer
(ebox-incremental-begin-batch buffer)
(ebox-region-update 'first :content "Outer retained"))
(let ((checkpoint (ebox-incremental--batch-state buffer)))
(should
(eq 'aborted
(catch 'batch-abort
(condition-case nil
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'first :content "Rejected first")
(ebox-region-update 'second :content "Rejected second")
(pcase exit-kind
('error (error "Abort callback"))
('quit (signal 'quit nil))
('throw (throw 'batch-abort 'aborted)))))
((error quit) 'aborted)))))
(should (eq checkpoint (ebox-incremental--batch-state buffer))))
(should (eq runtime (ebox--buffer-render-state buffer)))
(should (= revision (ebox-surface-buffer-revision buffer)))
(should (equal-including-properties before (buffer-string)))
(should (= (ebox-incremental-pending-count buffer) (if outer 1 0)))
(ebox-call-with-update-batch
(lambda () (ebox-region-update 'second :content "Next callback")))
(when outer (ebox-incremental-flush buffer))
(should (string-match-p (if outer "Outer retained" "First")
(buffer-string)))
(should (string-match-p "Next callback" (buffer-string)))
(should-not (string-match-p "Rejected" (buffer-string)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-update-batch-flush-failure-restores-committed-generation ()
"A failed TP publication should abort the owned batch and allow the next call."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer
(ebox-build '(column :width (px 180)
(box :id first "First")
(box :id second "Second")))
(let* ((buffer (current-buffer))
(before (buffer-string))
(runtime (ebox--buffer-render-state buffer))
(root (ebox-test--runtime-root buffer))
(revision (ebox-surface-buffer-revision buffer)))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject batch publication"))))
(should-error
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'first :content "Rejected first")
(ebox-region-update 'second :color "#112233")))))
(should-not (gethash buffer ebox-incremental--batch-table))
(should (eq runtime (ebox--buffer-render-state buffer)))
(should (eq root (ebox-test--runtime-root buffer)))
(should (= revision (ebox-surface-buffer-revision buffer)))
(should (equal-including-properties before (buffer-string)))
(ebox-call-with-update-batch
(lambda () (ebox-region-update 'second :content "Next callback")))
(should (string-match-p "First" (buffer-string)))
(should (string-match-p "Next callback" (buffer-string)))
(should-not (string-match-p "Rejected" (buffer-string)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only () (ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only ()
"Empty and non-empty flushes should update only the target buffer report." "Empty and non-empty flushes should update only the target buffer report."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
@ -7797,37 +8010,33 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count)
(render-prefix (plist-get state :render-content-prefix)) (let* ((state (ebox--scroll-get-state root-id))
(initial-lines (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height)) (initial-lines (length (plist-get state :content-lines)))
(boundary-offset (- initial-lines content-height))) (content-height (plist-get state :content-height))
(plist-put (boundary-offset (- initial-lines content-height)))
state :render-content-prefix
(lambda (&rest args) (with-current-buffer buffer
(cl-incf prefix-count) (should (= boundary-offset
(apply render-prefix args))) (ebox--scroll-region-by root-id
(puthash root-id state ebox--scroll-global-state) boundary-offset)))
(with-current-buffer buffer (should (= 4 (ebox--scroll-region-by root-id 4)))
(should (= boundary-offset (should (= (+ boundary-offset 4)
(ebox--scroll-region-by root-id (plist-get (ebox--scroll-get-state root-id)
boundary-offset))) :scroll-offset)))
(should (= 4 (ebox--scroll-region-by root-id 4))) (should (= (+ initial-lines 12)
(should (= (+ boundary-offset 4) (length
(plist-get (ebox--scroll-get-state root-id) (plist-get (ebox--scroll-get-state root-id)
:scroll-offset))) :content-lines))))
(should (= (+ initial-lines 12) (should
(length (= content-height
(plist-get (ebox--scroll-get-state root-id) (length
:content-lines)))) (plist-get
(should (plist-get (ebox--buffer-render-state buffer)
(= content-height :surface-fragments)
(length :scroll-lines))))))
(plist-get (should (= prefix-count 1)))))
(plist-get (ebox--buffer-render-state buffer)
:surface-fragments)
:scroll-lines))))))
(should (= prefix-count 1))))
(when root-id (when root-id
(ebox--smooth-scroll-stop root-id)) (ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -7861,23 +8070,19 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count)
(render-prefix (plist-get state :render-content-prefix)) (let* ((state (ebox--scroll-get-state root-id))
(initial-lines (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height)) (initial-lines (length (plist-get state :content-lines)))
(boundary-offset (- initial-lines content-height))) (content-height (plist-get state :content-height))
(plist-put (boundary-offset (- initial-lines content-height)))
state :render-content-prefix
(lambda (&rest args) (with-current-buffer buffer
(cl-incf prefix-count) (should (= boundary-offset
(apply render-prefix args))) (ebox--scroll-region-by root-id
(puthash root-id state ebox--scroll-global-state) boundary-offset)))
(with-current-buffer buffer (should (= 4 (ebox--scroll-region-by root-id 4 4)))))
(should (= boundary-offset (should (= prefix-count 1)))))
(ebox--scroll-region-by root-id
boundary-offset)))
(should (= 4 (ebox--scroll-region-by root-id 4 4)))))
(should (= prefix-count 1))))
(when root-id (when root-id
(ebox--smooth-scroll-stop root-id)) (ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -9238,32 +9443,20 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count materialize-count)
(render-prefix (plist-get state :render-content-prefix)) (let ((state (ebox--scroll-get-state root-id)))
(materialize (plist-get state :materialize-content-lines))) (should (= (length (plist-get state :content-lines)) 3)))
(should (= (length (plist-get state :content-lines)) 3)) (with-current-buffer buffer
(plist-put (should (ebox--smooth-scroll-region-by root-id 16)))
state :render-content-prefix (should (= prefix-count 1))
(lambda (&rest args) (should (= materialize-count 0))
(cl-incf prefix-count) (should (> (plist-get (ebox--scroll-get-state root-id)
(apply render-prefix args))) :scroll-offset)
(plist-put 1))
state :materialize-content-lines (let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(lambda (&rest args) (should entry)
(cl-incf materialize-count) (should (> (or (plist-get entry :pending) 0) 0))
(apply materialize args))) (should-not (plist-get entry :waiting-prefetch)))))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (= prefix-count 1))
(should (= materialize-count 0))
(should (> (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should (> (or (plist-get entry :pending) 0) 0))
(should-not (plist-get entry :waiting-prefetch))))
(ebox--smooth-scroll-stop root-id) (ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id) (ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -9301,32 +9494,20 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count materialize-count)
(render-prefix (plist-get state :render-content-prefix)) (let ((state (ebox--scroll-get-state root-id)))
(materialize (plist-get state :materialize-content-lines))) (should (= (length (plist-get state :content-lines)) 3)))
(should (= (length (plist-get state :content-lines)) 3)) (with-current-buffer buffer
(plist-put (should (ebox--smooth-scroll-region-by root-id 16))
state :render-content-prefix (ebox--smooth-scroll-tick root-id))
(lambda (&rest args) (should (> prefix-count 0))
(cl-incf prefix-count) (should (= materialize-count 0))
(apply render-prefix args))) (should (> (plist-get (ebox--scroll-get-state root-id)
(plist-put :scroll-offset)
state :materialize-content-lines 1))
(lambda (&rest args) (let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(cl-incf materialize-count) (should entry)
(apply materialize args))) (should-not (plist-get entry :waiting-prefetch)))))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16))
(ebox--smooth-scroll-tick root-id))
(should (> prefix-count 0))
(should (= materialize-count 0))
(should (> (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))))
(ebox--smooth-scroll-stop root-id) (ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id) (ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -9365,26 +9546,19 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count nil requested-lines)
(render-prefix (plist-get state :render-content-prefix))) (let ((state (ebox--scroll-get-state root-id)))
(should (= (length (plist-get state :content-lines)) 3)) (should (= (length (plist-get state :content-lines)) 3)))
(plist-put (with-current-buffer buffer
state :render-content-prefix (should (ebox--smooth-scroll-region-by root-id 1000)))
(lambda (state region-id required-lines) (should (= prefix-count 1))
(cl-incf prefix-count) (should (<= requested-lines 8))
(setq requested-lines required-lines) (should (<= (plist-get (ebox--scroll-get-state root-id)
(funcall render-prefix state region-id required-lines))) :scroll-offset)
(puthash root-id state ebox--scroll-global-state)) ebox-wheel-smooth-scroll-lines-per-tick))
(with-current-buffer buffer (let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should (ebox--smooth-scroll-region-by root-id 1000))) (should entry)
(should (= prefix-count 1)) (should-not (plist-get entry :waiting-prefetch)))))
(should (<= requested-lines 8))
(should (<= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
ebox-wheel-smooth-scroll-lines-per-tick))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))))
(ebox--smooth-scroll-stop root-id) (ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id) (ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -9421,25 +9595,19 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count)
(render-prefix (plist-get state :render-content-prefix)))
(plist-put (with-current-buffer buffer
state :render-content-prefix (should (ebox--smooth-scroll-region-by root-id 16)))
(lambda (&rest args) (should (gethash root-id ebox--smooth-scroll-state-table))
(cl-incf prefix-count) (let ((prefix-after-start prefix-count))
(apply render-prefix args))) (should (> prefix-after-start 0))
(puthash root-id state ebox--scroll-global-state)) (ebox--scroll-idle-prefetch root-id)
(with-current-buffer buffer (should (= prefix-count prefix-after-start))
(should (ebox--smooth-scroll-region-by root-id 16))) (should (gethash root-id ebox--scroll-idle-prefetch-timers))
(should (gethash root-id ebox--smooth-scroll-state-table)) (ebox--smooth-scroll-stop root-id)
(let ((prefix-after-start prefix-count)) (ebox--scroll-idle-prefetch root-id)
(should (> prefix-after-start 0)) (should (> prefix-count prefix-after-start)))))
(ebox--scroll-idle-prefetch root-id)
(should (= prefix-count prefix-after-start))
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-idle-prefetch root-id)
(should (> prefix-count prefix-after-start))))
(ebox--smooth-scroll-stop root-id) (ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id) (ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -9476,27 +9644,21 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*") (generate-new-buffer-name " *ebox-test*")
layout)) layout))
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id)) (ebox-test--with-scroll-producer-counts (prefix-count)
(render-prefix (plist-get state :render-content-prefix)))
(plist-put (with-current-buffer buffer
state :render-content-prefix (should (ebox--smooth-scroll-region-by root-id 16)))
(lambda (&rest args) (should (> prefix-count 0))
(cl-incf prefix-count) (let ((offset-before (plist-get (ebox--scroll-get-state root-id)
(apply render-prefix args))) :scroll-offset)))
(puthash root-id state ebox--scroll-global-state)) (ebox--smooth-scroll-tick root-id)
(with-current-buffer buffer (should (> (plist-get (ebox--scroll-get-state root-id)
(should (ebox--smooth-scroll-region-by root-id 16))) :scroll-offset)
(should (> prefix-count 0)) offset-before)))
(let ((offset-before (plist-get (ebox--scroll-get-state root-id) (let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
:scroll-offset))) (should entry)
(ebox--smooth-scroll-tick root-id) (should-not (plist-get entry :waiting-prefetch))
(should (> (plist-get (ebox--scroll-get-state root-id) (should (timerp (plist-get entry :timer))))))
:scroll-offset)
offset-before)))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))
(should (timerp (plist-get entry :timer)))))
(ebox--smooth-scroll-stop root-id) (ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id) (ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
@ -14224,4 +14386,105 @@ face patch must match that exactly (issue014)."
(should-not (gethash 999 actual)) (should-not (gethash 999 actual))
(should (= calls 36)))) (should (= calls 36))))
(ert-deftest ebox-content-box-owner-keeps-one-line-updates-local ()
"Canonical Text changes use their allocated Box, alone or beside a layer."
(dolist (with-layer '(nil t))
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 24) (ebox-viewport-height 8))
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(ebox-test--with-rendered-buffer
(ebox-build
'(column :width (vw 100) :padding-inline (ch 2)
(box :id host :width (ch 8) :height (lh 2)
(box :id card :position absolute :width (ch 4)
:height (lh 1) "CARD"))
(box :id status :padding-inline (ch 1)
:background-color "#112233" "Idle")
(box "FOLLOWING")))
(dolist (value '("Expanded" "ok"))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "One-line content update rendered the root"))))
(ebox-call-with-update-batch
(lambda ()
(when with-layer
(ebox-region-update 'card :left
(if (equal value "Expanded") '(ch 1) '(ch 0))))
(ebox-region-update 'status :content value))))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))
;; Growth that wraps must still take a wider proof/fallback.
(ebox-region-update 'status :content "This content needs several rows")
(should (ebox-test--buffer-propertized-matches-runtime-render-p))
(should (string-match-p "FOLLOWING" (buffer-string))))))))
(ert-deftest ebox-content-box-owner-keeps-disjoint-scroll-cache ()
"An allocated Box keeps unrelated scroll lines during growth and shrink."
(dolist (with-layer '(nil t))
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 24) (ebox-viewport-height 10))
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(ebox-test--with-rendered-buffer
(ebox-build
'(column :width (vw 100) :padding-inline (ch 2)
:background-color "#101117" :color "#DCE0EC"
(box :id host :width (ch 8) :height (lh 2)
(box :id card :position absolute :width (ch 4)
:height (lh 1) "CARD"))
(box :id status :padding-inline (ch 1)
:margin-inline (ch 1) :box-sizing content-box
:background-color "#112233" "Idle")
(column :id scroller :height (lh 2) :overflow scroll
(box "A") (box "B") (box "C") (box "D"))
(box "FOLLOWING")))
(let* ((scroll-id (ebox-test--selector-region-id
(current-buffer) "#scroller"))
(lines (plist-get (gethash scroll-id ebox--scroll-global-state)
:content-lines)))
(should lines)
(dolist (value '("Expanded" "ok"))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Disjoint scroll forced a root render"))))
(ebox-call-with-update-batch
(lambda ()
(when with-layer
(ebox-region-update
'card :left (if (equal value "Expanded") '(ch 1) '(ch 0))))
(ebox-region-update 'status :content value))))
(should (eq lines
(plist-get (gethash scroll-id ebox--scroll-global-state)
:content-lines)))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))))))
(ert-deftest ebox-content-box-owner-retains-painted-ancestors-in-batch ()
"Disjoint Box and layer updates keep their shared ancestor's properties."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 24) (ebox-viewport-height 8))
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(ebox-test--with-rendered-buffer
(ebox-build
'(column :width (vw 100) :padding-inline (ch 2)
:background-color "#101117" :color "#DCE0EC"
(box :id host :width (ch 8) :height (lh 2)
(box :id card :position absolute :width (ch 4)
:height (lh 1) "CARD"))
(box :id status :padding-inline (ch 1)
:background-color "#112233" "Idle")
(box "FOLLOWING")))
;; Equal-length transport must keep paint provenance for a later
;; variable-length update after another owner rebuilds the ledger.
(dolist (value '("Seed" "Expanded" "ok"))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Painted multi-owner update rendered the root"))))
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'card :left
(if (equal value "ok") '(ch 0) '(ch 1)))
(ebox-region-update 'status :content value))))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))))
;;; ebox-core-render-tests.el ends here ;;; ebox-core-render-tests.el ends here

View File

@ -273,8 +273,8 @@ MIXED starts with three integral rows before the fractional cached rows."
(cl-mapc (lambda (actual source) (should (eq actual source))) (cl-mapc (lambda (actual source) (should (eq actual source)))
lines (seq-take raw 3)))))))) lines (seq-take raw 3))))))))
(ert-deftest ebox-css-layout-fractional-scroll-keeps-wrapper-fallbacks () (ert-deftest ebox-css-layout-fractional-scroll-respects-wrapper-boundaries ()
"Nested or chrome-bearing fractional scrolls retain full-line projection." "Root chrome normalizes complete rows; nested fractional origins fall back."
(ebox-css-layout-test--with-pixel-display (ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (let ((ebox-viewport-width 101)
(ebox-viewport-height 3)) (ebox-viewport-height 3))
@ -292,7 +292,7 @@ MIXED starts with three integral rows before the fractional cached rows."
(cl-incf calls) (cl-incf calls)
(apply original args)))) (apply original args))))
(should (= (ebox--scroll-region-by region 1 1) 1))) (should (= (ebox--scroll-region-by region 1 1) 1)))
(should (= calls 1)) (should (= calls (if (eq boundary 'nested) 1 0)))
(should (equal (ebox-css-layout-test--display (buffer-string)) (should (equal (ebox-css-layout-test--display (buffer-string))
(ebox-css-layout-test--display (ebox-css-layout-test--display
(ebox-render (ebox-render

View File

@ -330,6 +330,138 @@
(kill-buffer target) (kill-buffer target)
(kill-buffer other)))) (kill-buffer other))))
(defun ebox-interaction-test--mouse-event (type window position)
"Return an event of TYPE at WINDOW's POSITION with its native click count."
(append (list type (list window position '(1 . 1) 0))
(cond
((memq 'drag (event-modifiers type))
(list (list window (1+ position) '(2 . 1) 1)))
((memq 'triple (event-modifiers type)) '(3))
((memq 'double (event-modifiers type)) '(2)))))
(defun ebox-interaction-test--dispatch-mouse (type window position)
"Dispatch a native event of TYPE at WINDOW's POSITION without moving point."
(let* ((event (ebox-interaction-test--mouse-event type window position))
(keys (vector event))
(command (with-current-buffer (window-buffer window)
(key-binding keys nil nil position)))
(map (with-current-buffer (window-buffer window)
(get-text-property position 'keymap))))
;; Resolve the event's actual window/position, not the caller's point.
;; Fail before invoking a global drag command that would read more input.
(should (commandp command))
(should (eq command (lookup-key map (vector type))))
(let ((last-input-event event))
(call-interactively command nil keys))))
(ert-deftest ebox-keymap-create-repeated-clicks-preserve-point-and-selection ()
"Press/release/drag sequences activate once per click without text selection."
(let ((target (generate-new-buffer " *ebox-click-target*"))
(other (generate-new-buffer " *ebox-click-other*"))
(calls 0) seen)
(unwind-protect
(save-window-excursion
(delete-other-windows)
(set-window-buffer (selected-window) other)
(let* ((selected (selected-window))
(window (split-window-right))
(map (ebox-keymap-create
:activate (lambda ()
(cl-incf calls)
(push (current-buffer) seen)
(ebox-region-update "action" :help-echo
(number-to-string calls))))))
(ebox-render-to-buffer
target (ebox-build (list 'column
(list 'text :id "action" :keymap map "action")
'(text "ordinary text"))))
(set-window-buffer window target)
(with-current-buffer other
(insert "other") (goto-char 2) (set-mark 4)
(setq mark-active nil)
(dolist (active '(nil t))
(with-current-buffer target
(setq buffer-read-only t)
(goto-char (- (point-max) 2)) (set-mark 3)
(setq mark-active active))
(let ((point-before (with-current-buffer target (point)))
(window-point-before (window-point window)))
(dolist (events '((down-mouse-1 mouse-1)
(double-down-mouse-1 double-mouse-1)
(triple-down-mouse-1 triple-mouse-1)))
(let ((before calls))
(ebox-interaction-test--dispatch-mouse (car events) window 1)
(should (= calls before))
(ebox-interaction-test--dispatch-mouse (cadr events) window 1)
(should (= calls (1+ before)))))
(let ((before calls))
(dolist (type '(drag-mouse-1 double-drag-mouse-1
triple-drag-mouse-1))
(ebox-interaction-test--dispatch-mouse type window 1))
(should (= calls before)))
(with-current-buffer target
(should (= (point) point-before))
(should (= (mark) 3))
(should (eq mark-active active)))
(should (= (window-point window) window-point-before))
(should (eq (current-buffer) other))
(should (= (point) 2))
(should (= (mark) 4))
(should-not mark-active)
(should (eq (selected-window) selected)))))
(should (= calls 6))
(should (cl-every (lambda (buffer) (eq buffer target)) seen))))
(kill-buffer target)
(kill-buffer other))))
(ert-deftest ebox-keymap-create-click-defaults-follow-replacement-and-removal ()
"Native click defaults follow published maps while explicit bindings win."
(let ((buffer (generate-new-buffer " *ebox-click-lifecycle*"))
(activations 0) (overrides 0))
(unwind-protect
(save-window-excursion
(let ((window (selected-window))
(map (ebox-keymap-create :activate (lambda ()
(cl-incf activations)))))
(ebox-render-to-buffer
buffer (ebox-build (list 'text :id "action" :keymap map "action")))
(set-window-buffer window buffer)
(with-current-buffer buffer
(ebox-region-update "action" :color "red")
(ebox-rerender-buffer-with-context buffer 80 30)
(ebox-interaction-test--dispatch-mouse 'double-mouse-1 window 1)
(should (= activations 1))
(ebox-region-update
"action" :keymap
(ebox-keymap-create
:activate (lambda () (cl-incf activations))
:bindings (mapcar (lambda (key)
(cons (vector key)
(lambda () (cl-incf overrides))))
'(down-mouse-1 double-mouse-1 triple-mouse-1
drag-mouse-1))))
(dolist (type '(down-mouse-1 double-mouse-1 triple-mouse-1
drag-mouse-1))
(ebox-interaction-test--dispatch-mouse type window 1))
(should (= overrides 4))
(should (= activations 1))
(ebox-interaction-test--dispatch-mouse 'mouse-1 window 1)
(should (= activations 2))
;; A raw map remains literal; replacing/removing the helper
;; restores native selection instead of leaving stale guards.
(ebox-region-update "action" :keymap '(keymap (mouse-1 . ignore)))
(let ((event (list 'down-mouse-1 (list window 1 '(1 . 1) 0))))
(should (eq (key-binding (vector event))
(global-key-binding [down-mouse-1]))))
(should-not (lookup-key (get-text-property 1 'keymap)
[double-mouse-1]))
(ebox-region-update "action" :keymap nil)
(dolist (type '(down-mouse-1 mouse-1 drag-mouse-1))
(let ((event (list type (list window 1 '(1 . 1) 0))))
(should (eq (key-binding (vector event))
(global-key-binding (vector type)))))))))
(kill-buffer buffer))))
(ert-deftest ebox-region-update-semantic-id-keeps-handle-lifecycle () (ert-deftest ebox-region-update-semantic-id-keeps-handle-lifecycle ()
"IDs resolve locally and opaque handles retain their owning surface checks." "IDs resolve locally and opaque handles retain their owning surface checks."
(let ((first (generate-new-buffer " *ebox-id-first*")) (let ((first (generate-new-buffer " *ebox-id-first*"))

View File

@ -42,6 +42,54 @@
(plist-get (ebox-surface-buffer-snapshot (current-buffer)) (plist-get (ebox-surface-buffer-snapshot (current-buffer))
:input)))))) :input))))))
(defun ebox-layer-publication-test--mount-signature (buffer)
"Return BUFFER's complete mounted ranges normalized to runtime tree paths."
(let* ((state (ebox--buffer-render-state buffer))
(objects (plist-get state :surface-node-object-table))
result)
(cl-labels
((walk (node path)
(let ((object (gethash (plist-get node :node-id) objects)))
(should object)
(push
(cons path
(sort
(mapcar (lambda (mount)
(cons (plist-get mount :start)
(plist-get mount :end)))
(tp-object-mounts object))
(lambda (left right)
(if (= (car left) (car right))
(< (cdr left) (cdr right))
(< (car left) (car right))))))
result))
(cl-loop for child in (ebox-tree-node-children node)
for index from 0
do (walk child (append path (list index))))))
(walk (plist-get state :root-node) '(root)))
(nreverse result)))
(defun ebox-layer-publication-test--assert-fresh-mounts ()
"Compare current mounts and paint with an independent committed-input render."
(let ((buffer (current-buffer))
(input (plist-get (ebox-surface-buffer-snapshot (current-buffer)) :input)))
(with-temp-buffer
(unwind-protect
(progn
(ebox-render-to-buffer (current-buffer) input)
(should (equal
(ebox-layer-publication-test--mount-signature buffer)
(ebox-layer-publication-test--mount-signature (current-buffer))))
(let ((actual (with-current-buffer buffer (buffer-string)))
(expected (buffer-string)))
(should (equal (substring-no-properties actual)
(substring-no-properties expected)))
(dotimes (position (length actual))
(should (equal (get-text-property position 'face actual)
(get-text-property position 'face expected))))))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer)))))))
(ert-deftest ebox-layer-publication-updates-every-placement-property () (ert-deftest ebox-layer-publication-updates-every-placement-property ()
"Each layer declaration reaches the mounted runtime through a region update." "Each layer declaration reaches the mounted runtime through a region update."
(dolist (properties '((:position relative) (:left (ch 2)) (:top (lh 1)) (dolist (properties '((:position relative) (:left (ch 2)) (:top (lh 1))
@ -181,6 +229,60 @@
(should (equal (get-text-property position 'help-echo) "lower-new"))) (should (equal (get-text-property position 'help-echo) "lower-new")))
(ebox-layer-publication-test--assert-fresh-render))) (ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-visible-mounts-follow-occlusion-and-rollback ()
"Hide/reveal remounts visible owners and rolls back both TP failure stages."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :id "root" :width (ch 12) :height (lh 2)
:padding-inline (ch 2) :background-color "#101117" :color "#DCE0EC"
:border-top-width (px 1) :border-top-style solid
:border-top-color "#ABCDEF"
:border-bottom-width (px 1) :border-bottom-style solid
:border-bottom-color "#FEDCBA"
(box :id "host" :width (ch 8) :height (lh 2)
(box :id "lower" :width (ch 8) :height (lh 2)
:background-color "#234567" :help-echo "lower"
"LOWER001\nLOWER002")
(box :id "upper" :position absolute :width (ch 8) :height (lh 1)
:background-color "#AABBCC" :help-echo "upper" "UPPER001")))))
(ebox-region-update "lower" :keymap '(keymap (13 . backward-char)))
(ebox-region-update "upper" :keymap '(keymap (13 . forward-char)))
(dolist (failure-step '(text client-state))
(let ((before (buffer-string))
(state (ebox--buffer-render-state (current-buffer)))
(revision (ebox-surface-buffer-revision (current-buffer)))
(mounts (ebox-layer-publication-test--mount-signature (current-buffer))))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step) (error "Reject layer mounts")))))
(should-error (ebox-region-update "upper" :visibility 'hidden)))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(should (equal mounts
(ebox-layer-publication-test--mount-signature (current-buffer)))))
(dolist (visibility '(hidden visible))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Layer remount rendered the root"))))
(ebox-region-update "upper" :visibility visibility))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(upper (car (ebox-selector-query-buffer (current-buffer) "#upper")))
(object (gethash (plist-get upper :node-id)
(plist-get state :surface-node-object-table))))
(should (eq (null (tp-object-mounts object)) (eq visibility 'hidden)))
(should (eq (null (ebox-surface-region-bounds
(current-buffer) (plist-get upper :region-id)))
(eq visibility 'hidden)))
(goto-char (point-min))
(search-forward (if (eq visibility 'hidden) "LOWER001" "UPPER001"))
(should (equal (get-text-property (1- (point)) 'help-echo)
(if (eq visibility 'hidden) "lower" "upper")))
(should (eq (lookup-key (get-text-property (1- (point)) 'keymap) (kbd "RET"))
(if (eq visibility 'hidden) #'backward-char #'forward-char))))
(ebox-layer-publication-test--assert-fresh-mounts)))))
(ert-deftest ebox-layer-publication-ancestor-paint-survives-recomposition () (ert-deftest ebox-layer-publication-ancestor-paint-survives-recomposition ()
"A detached host retains enclosing paint and interaction contributions." "A detached host retains enclosing paint and interaction contributions."
(ebox-layer-publication-test--with-buffer (ebox-layer-publication-test--with-buffer
@ -218,6 +320,45 @@
(should (string-match-p "LOWER002" (buffer-string))) (should (string-match-p "LOWER002" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render))) (ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-hidden-portals-keep-unrelated-host-local ()
"Hidden portals cannot widen unrelated local updates, even after a reveal."
(dolist (hidden-owner '(nil t))
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :id "root" :width (ch 12) :height (lh 5)
(box :id "host" :width (ch 8) :height (lh 2)
(box :id "lower" :width (ch 8) :height (lh 1) "LOWER001")
(box :id "upper" :position absolute :width (ch 8)
:height (lh 1) :z-index 1 "UPPER001"))
(box :id "portal-owner" :height (lh 1)
:visibility ,(if hidden-owner 'hidden 'visible)
(box :id "portal" :position absolute :layer root
:width (ch 8) :height (lh 1) :z-index 2
:visibility ,(if hidden-owner 'visible 'hidden) "PORTAL01")))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Hidden portal caused an unrelated root render"))))
(ebox-region-update "lower" :content "LOWER002")
(ebox-region-update "upper" :visibility 'hidden))
(should (string-match-p "LOWER002" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)
(ebox-region-update (if hidden-owner "portal-owner" "portal")
:visibility 'visible)
(should (string-match-p "PORTAL01" (buffer-string)))
(ebox-region-update "lower" :content "LOWER003")
(should-not (string-match-p "LOWER003" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)
(ebox-region-update (if hidden-owner "portal-owner" "portal")
:visibility 'hidden)
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Closed portal retained a stale root dependency"))))
(ebox-region-update "lower" :content "LOWER004"))
(should (string-match-p "LOWER004" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render))))
(ert-deftest ebox-layer-publication-root-portal-occludes-unrelated-owner () (ert-deftest ebox-layer-publication-root-portal-occludes-unrelated-owner ()
"Updates outside a root portal's local host cannot paint over the portal." "Updates outside a root portal's local host cannot paint over the portal."
(ebox-layer-publication-test--with-buffer (ebox-layer-publication-test--with-buffer
@ -319,5 +460,186 @@
(should (string-match-p "Before" (buffer-string))) (should (string-match-p "Before" (buffer-string)))
(should (string-match-p "After" (buffer-string))))))) (should (string-match-p "After" (buffer-string)))))))
(defun ebox-layer-publication-test--allocated-input (&rest host-style)
"Build a host with HOST-STYLE inside a padded parent allocation."
(ebox-build
`(column :id "root" :width (ch 24) :height (lh 7)
:padding ((lh 1) (ch 2))
(box :id "before" "Before")
(box :id "host" :height (lh 2) :background-color "#004488"
,@host-style
(box :id "lower" :width (ch 8) :height (lh 1) "LOWER001")
(box :id "upper" :position absolute :left (px 0) :top (lh 0)
:width (ch 4) :height (lh 1) :background-color "#884400"
:help-echo "Upper layer" :pointer hand
:keymap (keymap (13 . ignore)) "UP01"))
(box :id "after" "After"))))
(defun ebox-layer-publication-test--assert-fresh-properties ()
"Compare visible text, geometry, paint and interactions with a fresh render."
(let ((actual (buffer-string))
(fresh (ebox-render
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
:input))))
(should (equal (substring-no-properties actual)
(substring-no-properties fresh)))
(dotimes (position (length actual))
(dolist (property '(face font-lock-face display help-echo keymap
mouse-face pointer))
(ert-info ((format "Property %S at character %d" property position))
(should (equal (get-text-property position property actual)
(get-text-property position property fresh))))))))
(ert-deftest ebox-layer-publication-auto-host-keeps-padded-parent-allocation ()
"An auto-width host keeps its parent's content width during local updates."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--allocated-input))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Auto-width layer host rendered the root"))))
(dolist (left '((ch 1) (ch 0)))
(ebox-region-update "upper" :left left)
(ebox-layer-publication-test--assert-fresh-properties)))))
(ert-deftest ebox-layer-publication-allocation-respects-host-box-model ()
"A local allocation includes margins and content-box padding exactly once."
(dolist (style '((:width (ch 8) :margin-inline (ch 1))
(:width (ch 8) :box-sizing content-box
:padding-inline (ch 1))))
(ert-info ((format "Host style %S" style))
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(apply #'ebox-layer-publication-test--allocated-input style))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Stable host box model rendered the root"))))
(dolist (left '((ch 1) (ch 0)))
(ebox-region-update "upper" :left left)
(ebox-layer-publication-test--assert-fresh-properties)))))))
(ert-deftest ebox-layer-publication-intrinsic-host-width-can-grow ()
"Content-dependent hosts must grow naturally and move their following sibling."
(dolist (width '(max-content min-content fit-content auto))
(ert-info ((format "Intrinsic host width %S" width))
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(row :width (ch 20) :height (lh 2)
(box :id "host" :width ,width :height (lh 1)
(box :id "lower" "AAAA")
(box :position absolute :width (ch 2)
:height (lh 1) "XX"))
(box "TAIL"))))
(dolist (content '("AAAAAA" "AAA"))
(ebox-region-update "lower" :content content)
(ebox-layer-publication-test--assert-fresh-properties))))))
(ert-deftest ebox-layer-publication-inline-auto-host-width-can-grow ()
"Automatic inline widths remain content-dependent even under normal flow."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(box :width (ch 20) :height (lh 2)
(box :id "host" :outer inline :height (lh 1)
(box :id "lower" :outer inline "AAAA")
(box :position absolute :width (ch 2)
:height (lh 1) "XX"))
(box :outer inline "TAIL"))))
(dolist (content '("AAA" "AAAAAA"))
(ebox-region-update "lower" :content content)
(ebox-layer-publication-test--assert-fresh-properties))))
(ert-deftest ebox-layer-publication-owner-geometry-does-not-reuse-allocation ()
"Owner width, sizing model and margin changes retain the geometry fallback."
(dolist (case '(((:width (ch 8)) (:width (ch 10)))
((:width (ch 8) :padding-inline (ch 1))
(:box-sizing content-box))
((:width (ch 8) :margin-inline (ch 1))
(:margin-inline (ch 2)))))
(ert-info ((format "Host geometry change %S" case))
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(apply #'ebox-layer-publication-test--allocated-input (car case)))
(let ((render (symbol-function 'ebox-surface--render-candidate))
(full-renders 0))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf full-renders)
(apply render arguments))))
(apply #'ebox-region-update "host" (cadr case)))
(should (> full-renders 0)))
(ebox-layer-publication-test--assert-fresh-properties)))))
(ert-deftest ebox-layer-publication-allocated-host-failure-rolls-back ()
"Failed local text and client-state publication preserve the old allocation."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--allocated-input))
(let ((before (buffer-string))
(revision (ebox-surface-buffer-revision (current-buffer))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Allocated layer rollback rendered the root"))))
(dolist (failure-step '(text client-state))
(let* ((failed-step nil)
(tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step)
(setq failed-step step)
(error "Reject allocated layer %S" step)))))
(should-error (ebox-region-update "upper" :left '(ch 1)))
(should (eq failed-step failure-step)))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(ebox-layer-publication-test--assert-fresh-properties))
(ebox-region-update "upper" :left '(ch 1))
(should (= (1+ revision)
(ebox-surface-buffer-revision (current-buffer))))
(should-not (equal-including-properties before (buffer-string)))
(ebox-layer-publication-test--assert-fresh-properties)))))
(ert-deftest ebox-layer-publication-hidden-ancestor-portals-stay-hidden-in-active-root ()
"Root composition must not collect portals under a hidden flow ancestor."
(dolist (other-root-paint '(absolute portal))
(ert-info ((format "Other root paint %S" other-root-paint))
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(box :width (ch 12) :height (lh 3)
(box "BASE00000000\nABCDEFGHIJKL\n0123456789ab")
(box :id "hidden-owner" :visibility hidden :height (lh 1)
(box :height (lh 1)
(box :id "hidden-portal" :position absolute :layer root
:left (ch 0) :top (lh 0) :width (ch 8)
:height (lh 1) :z-index 9 "HIDDEN01")))
,(if (eq other-root-paint 'absolute)
'(box :position absolute :left (ch 10) :top (lh 2)
:width (ch 2) :height (lh 1) "XY")
'(box :height (lh 1)
(box :position absolute :layer root
:left (ch 10) :top (lh 2)
:width (ch 2) :height (lh 1) "XY"))))))
(should-not (string-match-p "HIDDEN01" (buffer-string)))
(should (string-match-p "XY" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-properties)
(ebox-region-update "hidden-portal" :content "HIDDEN02")
(should-not (string-match-p "HIDDEN02" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-properties)
(dotimes (_ 2)
(ebox-region-update "hidden-owner" :visibility 'visible)
(should (string-match-p "HIDDEN02" (buffer-string)))
(should (string-match-p "XY" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-properties)
(ebox-region-update "hidden-owner" :visibility 'hidden)
(should-not (string-match-p "HIDDEN02" (buffer-string)))
(should (string-match-p "XY" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-properties))))))
(provide 'ebox-layer-publication-tests) (provide 'ebox-layer-publication-tests)
;;; ebox-layer-publication-tests.el ends here ;;; ebox-layer-publication-tests.el ends here

View File

@ -0,0 +1,348 @@
;;; ebox-layer-scroll-tests.el --- Retained layered root scroll -*- lexical-binding: t; -*-
;;; Commentary:
;; Cached root scroll projects composed content without repositioning its layers.
;; Nested owners and active root projections keep their composition boundary.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(defmacro ebox-layer-scroll-test--with-buffer (&rest body)
"Run BODY in an isolated Elisp scroll buffer without native window scrolling."
(declare (indent 0) (debug t))
`(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(let ((ebox-viewport-width 24) (ebox-viewport-height 8)
(ebox-native-buffer-scroll nil)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(ebox-scroll-lazy-idle-prefetch-lines 0))
(with-temp-buffer ,@body))))
(defun ebox-layer-scroll-test--input (&optional chrome portal nested)
"Build a layered scrolling root with CHROME, hidden PORTAL and NESTED owner."
(ebox-build
`(column :id "root" :width (ch 20) :height (lh 5) :overflow scroll
:padding ,(pcase chrome
('fractional '((lh 1) (px 1.5)))
('nil '((lh 0) (ch 0)))
(_ '((lh 1) (ch 1))))
:color "#102030" :background-color "#DDEEFF"
(box :id "before" "BEFORE")
(box :id "host" :height (lh 4)
(box :id "lower" "BASE0000\nBASE1111\nBASE2222\nBASE3333")
(box :id "upper" :position absolute :left (ch 2) :top (lh 1)
:width (ch 4) :height (lh 2) :background-color "#334455"
:color "#FFFFFF" :pointer hand :help-echo "layer action"
:keymap (keymap (13 . ignore)) "TOP1\nTOP2"))
(box :id "after" "AFTER000\nAFTER111\nAFTER222\nAFTER333")
,@(when nested
'((box :id "nested" :width (ch 10) :height (lh 2)
:overflow scroll :padding ((lh 1) (ch 1))
"NESTED0\nNESTED1\nNESTED2\nNESTED3")))
,@(when portal
'((box :id "portal-owner" :height (lh 1)
(box :id "portal" :position absolute :layer root
:anchor "before" :visibility hidden
:width (ch 6) :height (lh 1) "PORTAL")))))))
(defun ebox-layer-scroll-test--root-state ()
"Return the mounted root's current scroll state."
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(root (plist-get runtime :root-node)))
(gethash (plist-get root :region-id) (plist-get runtime :scroll-state-table))))
(defun ebox-layer-scroll-test--portal-chain-input ()
"Build two visible anchored root projections through a clipped logical owner."
(ebox-build
'(column :id "root" :width (ch 20) :height (lh 6) :overflow scroll
:padding ((lh 1) (px 1.5)) :margin-left (px 0.5)
:background-color "#EEF4F8" :color "#173047"
(box "PRE0\nPRE1\nPRE2")
(box :id "clipped-owner" :height (lh 1) :overflow hidden
(box :id "trigger" :position relative :left (ch 1)
:width (ch 6) :height (lh 1) "TRIGGER")
;; Source order is the reverse of the anchor dependency order.
(box :id "dependent" :position absolute :layer root
:anchor "provider-anchor" :placement bottom-end :z-index 9
:width (ch 5) :height (lh 1) :background-color "#704080"
:color "#FFFFFF" :pointer hand :help-echo "Dependent action"
:keymap (keymap (13 . ignore)) "CHAIN")
(box :id "provider" :position absolute :layer root :anchor "trigger"
:placement bottom-start :width (ch 7) :height (lh 2)
:background-color "#206050" :color "#FFFFFF"
(box :id "provider-anchor" :position relative :left (ch 1)
:width (ch 4) :height (lh 1) :help-echo "Provider action"
:keymap (keymap (13 . ignore)) "LINK")
(box "PANEL")))
(box "POST0\nPOST1\nPOST2\nPOST3\nPOST4\nPOST5\nPOST6\nPOST7"))))
(defun ebox-layer-scroll-test--assert-fresh ()
"Compare the committed window with an independent render at the same offset."
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(root (plist-get runtime :root-node))
(offset (ebox-get root :scroll-offset))
(input (plist-get (ebox-surface-buffer-snapshot (current-buffer)) :input))
(ebox-viewport-width (plist-get runtime :viewport-width))
(ebox-viewport-height (plist-get runtime :viewport-height))
(set-offset
(lambda (candidate)
(unless (eq candidate root)
(ebox-put candidate :scroll-offset offset)
(ebox-put candidate :ebox-scroll-offset-controlled-p t))))
actual fresh)
(setq actual (buffer-string))
;; Bind only the first layout invocation, leaving nested scroll offsets
;; to the committed source used by this root-only oracle.
(let ((render (symbol-function 'ebox--render-layout)) first)
(cl-letf (((symbol-function 'ebox--render-layout)
(lambda (candidate)
(unless first (setq first t) (funcall set-offset candidate))
(funcall render candidate))))
(setq fresh (ebox-render input))))
(should (equal (substring-no-properties actual) (substring-no-properties fresh)))
(should (= (length actual) (length fresh)))
(dotimes (position (length actual))
(dolist (property '(face font-lock-face display help-echo keymap mouse-face pointer))
(ert-info ((format "Property %S at %d" property position))
(should (equal (get-text-property position property actual)
(get-text-property position property fresh))))))))
(ert-deftest ebox-layer-scroll-root-reuses-composed-content ()
"Pure root scroll retains layered content and skips the complete tree render."
(dolist (chrome '(nil t fractional))
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input chrome t))
(let* ((state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id))
(content (plist-get state :content-lines))
(root-renders 0)
(render (symbol-function 'ebox-surface--render-candidate)))
(when (eq chrome 'fractional)
(should (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p)))
(dolist (delta '(1 1 -1 -1))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf root-renders)
(apply render arguments))))
(should (= delta (ebox--scroll-region-by region-id delta 1)))
(should (eq content
(plist-get (ebox-layer-scroll-test--root-state) :content-lines)))
(should (eq (plist-get (ebox-buffer-update-report (current-buffer))
:projection-kind)
'scroll-patch)))
(ebox-layer-scroll-test--assert-fresh))
(should (= root-renders 0))))))
(ert-deftest ebox-layer-scroll-retains-input-facts-with-unready-nested-window ()
"A nested window's chrome cannot invalidate the root's style or axis facts."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input t nil t))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(root-state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get root-state :box) :region-id))
(axes (plist-get runtime :viewport-dependent-node-id-axes))
(nested (car (ebox-selector-query-buffer (current-buffer) "#nested")))
(nested-state (gethash (plist-get nested :region-id)
(plist-get runtime :scroll-state-table))))
(should nested-state)
(should-not (ebox--scroll-state-retained-window-ready-p nested-state))
(cl-letf (((symbol-function 'ebox-surface--context-axes)
(lambda (&rest _) (ert-fail "Scroll rescanned context axes")))
((symbol-function 'ebox-surface--inline-inheritance-required-p)
(lambda (&rest _) (ert-fail "Scroll rescanned inheritance")))
((symbol-function 'ebox-surface--apply-node-style)
(lambda (&rest _) (ert-fail "Scroll reapplied unchanged styles"))))
(should (= 1 (ebox--scroll-region-by region-id 1 1))))
(should (eq axes (plist-get (ebox--buffer-render-state (current-buffer))
:viewport-dependent-node-id-axes)))
(ebox-layer-scroll-test--assert-fresh))))
(ert-deftest ebox-layer-scroll-content-and-visibility-refresh-composed-cache ()
"Content and visibility updates replace the cache consumed by later scrolling."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input t))
(let* ((root-state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get root-state :box) :region-id)))
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(dolist (change '((:content "NEW1\nNEW2")
(:visibility hidden) (:visibility visible)))
(let ((content (plist-get (ebox-layer-scroll-test--root-state) :content-lines)))
(apply #'ebox-region-update "upper" change)
(should-not (eq content (plist-get (ebox-layer-scroll-test--root-state)
:content-lines))))
(ebox-layer-scroll-test--assert-fresh)
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(ebox-layer-scroll-test--assert-fresh)
(should (= -1 (ebox--scroll-region-by region-id -1 1)))))))
(ert-deftest ebox-layer-scroll-rejected-publication-keeps-cache-and-input ()
"Both publication failure stages preserve composed cache and current offset."
(dolist (failure-step '(text client-state))
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input 'fractional))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id))
(content (plist-get state :content-lines))
(snapshot (ebox-surface-buffer-snapshot (current-buffer)))
(revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string)))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step) (error "Reject cached root scroll")))))
(should-error (ebox--scroll-region-by region-id 1 1)))
(should (eq runtime (ebox--buffer-render-state (current-buffer))))
(should (eq state (ebox-layer-scroll-test--root-state)))
(should (eq content (plist-get state :content-lines)))
(let* ((after (ebox-surface-buffer-snapshot (current-buffer)))
(same-input
(equal (ebox-canonical-input-roots (plist-get snapshot :input))
(ebox-canonical-input-roots (plist-get after :input)))))
(should same-input)
(should (= (plist-get snapshot :mount-id) (plist-get after :mount-id))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(ebox-layer-scroll-test--assert-fresh)))))
(ert-deftest ebox-layer-scroll-visible-root-dependencies-retain-composed-cache ()
"Pure root scroll slices already composed visible projections and anchors."
(dolist (target '(portal anchor))
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input t t))
(if (eq target 'portal)
(ebox-region-update "portal" :visibility 'visible)
(ebox-region-update "upper" :anchor "before"))
(let* ((state (ebox-layer-scroll-test--root-state))
(root (plist-get state :box))
(region-id (plist-get root :region-id))
(render (symbol-function 'ebox-surface--render-candidate))
(root-renders 0))
(should (ebox-layer-cached-root-scroll-p root state))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf root-renders)
(apply render arguments))))
(should (= 1 (ebox--scroll-region-by region-id 1 1))))
(should (= root-renders 0))
(ebox-layer-scroll-test--assert-fresh)))))
(ert-deftest ebox-layer-scroll-anchor-provider-chain-crosses-viewport ()
"Cached provider chains keep text and interactions when entering and leaving."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-layer-scroll-test--portal-chain-input))
(let* ((state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id))
(content (plist-get state :content-lines))
(visible 0) (outside 0))
(should (string-match-p "CHAIN" (ebox-lines-join content)))
(dolist (delta '(1 1 1 1 1 1 -1 -1 -1 -1 -1 -1))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Root scroll recomposed portals"))))
(should (= delta (ebox--scroll-region-by region-id delta 1))))
(should (eq content (plist-get (ebox-layer-scroll-test--root-state)
:content-lines)))
(if (string-match-p "CHAIN" (buffer-string))
(cl-incf visible)
(cl-incf outside))
(ebox-layer-scroll-test--assert-fresh))
(should (> visible 0))
(should (> outside 0)))))
(ert-deftest ebox-layer-scroll-anchor-provider-invalidation-and-rollback ()
"Provider changes replace composed caches; rejected scrolls preserve them."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-layer-scroll-test--portal-chain-input))
(let* ((state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id)))
(should (= 3 (ebox--scroll-region-by region-id 3 3)))
(dolist (change '(("provider-anchor" :content "EDIT")
("provider" :placement top-end)
("trigger" :left (ch 3))
("clipped-owner" :visibility hidden)
("clipped-owner" :visibility visible)))
(let ((content (plist-get (ebox-layer-scroll-test--root-state) :content-lines)))
(apply #'ebox-region-update change)
(should-not (eq content (plist-get (ebox-layer-scroll-test--root-state)
:content-lines))))
(ebox-layer-scroll-test--assert-fresh)
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(ebox-layer-scroll-test--assert-fresh)
(should (= -1 (ebox--scroll-region-by region-id -1 1))))
(dolist (failure-step '(text client-state))
(let ((runtime (ebox--buffer-render-state (current-buffer)))
(content (plist-get (ebox-layer-scroll-test--root-state) :content-lines))
(before (buffer-string))
(revision (ebox-surface-buffer-revision (current-buffer))))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step) (error "Reject anchored root scroll")))))
(should-error (ebox--scroll-region-by region-id 1 1)))
(should (eq runtime (ebox--buffer-render-state (current-buffer))))
(should (eq content (plist-get (ebox-layer-scroll-test--root-state) :content-lines)))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))))
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(ebox-layer-scroll-test--assert-fresh))))
(ert-deftest ebox-layer-scroll-declines-incomplete-or-changed-cache ()
"Dirty or lazy content and simultaneous source changes cannot reuse layers."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-scroll-test--input t))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(state (ebox-layer-scroll-test--root-state))
(root (plist-get state :box))
(owner (plist-get root :node-id)))
(dolist (key '(:render-content-prefix :materialize-content-lines
:lazy-scroll-prefix-dirty :lazy-scroll-window-refresh-required))
(let ((candidate (copy-sequence state)))
(plist-put candidate :content-lines-complete-p nil)
(setq candidate (plist-put candidate key t))
(should-not (ebox-layer-cached-root-scroll-p root candidate))))
(dolist (keys '((:scroll-offset :content) (:visibility) (:width)))
(should-not
(ebox-incremental--layer-root-scroll-p
runtime runtime
(list :dirty-set (list (list :node-id owner :changed-keys keys)))
owner))))))
(ert-deftest ebox-layer-scroll-ordinary-materialized-content-builds-rendered-rows ()
"Ordinary materialization still bootstraps its reusable physical row cache."
(ebox-layer-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :id "root" :width (vw 100) :height (vh 100) :overflow scroll
,@(cl-loop for index below 80
collect `(box :padding-left (px 1.5)
,(format "ROW%02d" index))))))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(state (ebox-layer-scroll-test--root-state))
(region-id (plist-get (plist-get state :box) :region-id)))
(should-not (plist-get runtime :layered-p))
(should (plist-get runtime :fractional-pixel-output-p))
(setq state (ebox--scroll-state-materialize-lines region-id state))
(puthash region-id state (plist-get runtime :scroll-state-table))
(should (plist-get state :content-lines-complete-p))
(should-not (plist-get state :rendered-content-lines))
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(should (= -1 (ebox--scroll-region-by region-id -1 1)))
(should (plist-get (ebox-layer-scroll-test--root-state) :rendered-content-lines))
(cl-letf (((symbol-function 'ebox-surface--render-candidate-node)
(lambda (&rest _) (ert-fail "Cached ordinary scroll rendered wrapper"))))
(should (= 1 (ebox--scroll-region-by region-id 1 1))))
(ebox-layer-scroll-test--assert-fresh))))
(provide 'ebox-layer-scroll-tests)
;;; ebox-layer-scroll-tests.el ends here

View File

@ -160,5 +160,105 @@
(should (string-match-p "LOWER002" (buffer-string))) (should (string-match-p "LOWER002" (buffer-string)))
(should-not (string-match-p "UPPER001" (buffer-string)))))) (should-not (string-match-p "UPPER001" (buffer-string))))))
(ert-deftest ebox-layer-pieces-preserve-owner-margins-and-property-boundaries ()
"Nested ownership and each owner's margins produce separate exact pieces."
(let* ((parent (list :node-id 1 :region-id 0))
(child (list :node-id 2 :region-id "child"))
(line (propertize "ABCDEFGH" 'ebox-content-owners '(0 "child")))
(second (propertize "XY" 'ebox-content "child"))
(lines (list line second)))
(put-text-property 0 1 'ebox-ml "child" line)
(put-text-property 3 4 'ebox-mt "child" line)
(put-text-property 6 7 'ebox-mb "child" line)
(put-text-property 7 8 'ebox-mr "child" line)
;; Duplicate roles and unrelated face boundaries do not split ownership.
(put-text-property 1 2 'ebox-content-owner "child" line)
(put-text-property 2 3 'face 'bold line)
(put-text-property 4 5 'ebox-pl "child" line)
(put-text-property 5 6 'ebox-br "child" line)
(cl-letf (((symbol-function 'ebox--string-pixel-width) #'length))
(let ((pieces (ebox-layer--pieces lines parent)))
(should (equal pieces (list (list :line line :start 0 :end 8 :y 0)))))
(let ((pieces (ebox-layer--pieces lines child)))
(should (equal pieces
(list (list :line line :start 1 :end 3 :y 0)
(list :line line :start 4 :end 6 :y 0)
(list :line second :start 0 :end 2 :y 1))))
(should (eq (plist-get (car pieces) :line) line))))))
(ert-deftest ebox-layer-pieces-keep-shaping-graphemes-and-shared-display-source ()
"An ownership scan preserves original shaping and complete display runs."
(let* ((node (list :node-id 1 :region-id "owner"))
(display (list 'space :width '(15)))
(keymap (make-sparse-keymap))
(line (propertize "fi éabcQ" 'ebox-content-owner "owner"
'keymap keymap))
;; Original prefixes: fi shapes, the combining mark has no advance,
;; and abc is one EQ replacement run despite property boundaries.
(widths [0 7 11 18 25 25 40 40 40 47]))
(put-text-property 5 8 'display display line)
(dotimes (index (length line))
(put-text-property index (1+ index) 'source-position index line))
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (source)
(should (equal-including-properties
source (substring line 0 (length source))))
(aref widths (length source)))))
(let* ((pieces (ebox-layer--pieces (list line) node))
(source (plist-get (car pieces) :line)))
(should (equal pieces (list (list :line line :start 0 :end 47 :y 0))))
(should (eq source line))
(should (eq (get-text-property 5 'display source) display))
(should (eq (get-text-property 7 'display source) display))
(should (eq (get-text-property 0 'keymap source) keymap))))))
(ert-deftest ebox-layer-pieces-share-prefix-measurements-within-one-composition ()
"Sibling and anchor lookups share metrics without sharing mutable pieces."
(let* ((line (propertize "ABCD" 'ebox-content-owners '(0 "child" "child")))
(lines (list line))
(parent (list :node-id 1 :region-id 0))
(child (list :node-id 2 :region-id (copy-sequence "child")))
(measurements 0)
(ebox-layer--piece-width-cache (make-hash-table :test #'eq)))
(put-text-property 0 1 'ebox-ml "child" line)
(put-text-property 3 4 'ebox-mr "child" line)
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (source)
(cl-incf measurements)
(length source))))
(let ((parent-pieces (ebox-layer--pieces lines parent))
(child-pieces (ebox-layer--pieces lines child)))
(should (= measurements 3))
(dotimes (_ 10)
(should (equal parent-pieces (ebox-layer--pieces lines parent)))
(should (equal child-pieces (ebox-layer--pieces lines child))))
(should (= measurements 3))
(should (equal parent-pieces
(list (list :line line :start 0 :end 4 :y 0))))
(should (equal child-pieces
(list (list :line line :start 1 :end 3 :y 0))))
(should-not (eq (car parent-pieces) (car child-pieces))))
;; An independently composed equal source keeps its own source identity.
(let* ((copy (copy-sequence line))
(pieces (ebox-layer--pieces (list copy) parent)))
(should (eq (plist-get (car pieces) :line) copy))
(should (= measurements 6))))))
(ert-deftest ebox-layer-pieces-standalone-does-not-retain-prefix-widths ()
"An isolated lookup rebuilds metrics after measurement context changes."
(let* ((node (list :node-id 1 :region-id "owner"))
(line (propertize "AB" 'ebox-content-owner "owner"))
(lines (list line))
(scale 1)
(ebox-layer--piece-width-cache nil))
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (source) (* scale (length source)))))
(should (= (plist-get (car (ebox-layer--pieces lines node)) :end) 2))
(setq scale 2)
(should (= (plist-get (car (ebox-layer--pieces lines node)) :end) 4))
(remove-text-properties 0 (length line) '(ebox-content-owner nil) line)
(should-not (ebox-layer--pieces lines node))
(should-not ebox-layer--piece-width-cache))))
(provide 'ebox-layer-tests) (provide 'ebox-layer-tests)
;;; ebox-layer-tests.el ends here ;;; ebox-layer-tests.el ends here

View File

@ -94,6 +94,7 @@
((string-match-p "canonical" name) 'ebox-canonical) ((string-match-p "canonical" name) 'ebox-canonical)
((string-match-p "render-burst" name) 'ebox-buffer-backend) ((string-match-p "render-burst" name) 'ebox-buffer-backend)
((string-match-p "render-owned-text-values" name) 'ebox-render-context) ((string-match-p "render-owned-text-values" name) 'ebox-render-context)
((string-prefix-p "ebox-layer--" name) 'ebox-layer)
((string-match-p "font\\|char-width\\|pixel-width\\|face-height\\|space-pixel\\|display-signature" name) ((string-match-p "font\\|char-width\\|pixel-width\\|face-height\\|space-pixel\\|display-signature" name)
'ebox-measure) 'ebox-measure)
((string-match-p "style" name) 'ebox-style) ((string-match-p "style" name) 'ebox-style)
@ -153,6 +154,7 @@
ebox-incremental--allocated-slot-proof-cache ebox-incremental--allocated-slot-proof-cache
ebox-incremental--candidate-path-copy-origin-table ebox-incremental--candidate-path-copy-origin-table
ebox-incremental--candidate-proof-node-table ebox-incremental--candidate-proof-node-table
ebox-layer--piece-width-cache
ebox-style--box-engine-longhand-cache ebox-style--closed-computed-cache ebox-style--box-engine-longhand-cache ebox-style--closed-computed-cache
ebox-style--closed-inheritance-cache ebox-style--computed-snapshot-cache ebox-style--closed-inheritance-cache ebox-style--computed-snapshot-cache
ebox-style--declaration-cache ebox-style--text-engine-longhand-cache ebox-style--declaration-cache ebox-style--text-engine-longhand-cache

View File

@ -0,0 +1,372 @@
;;; ebox-nested-scroll-tests.el --- Cached nested scroll publication -*- lexical-binding: t; -*-
;;; Commentary:
;; Fixed scroll allocations publish their retained windows locally while
;; preserving ancestor effects, native interaction and TP mount ownership.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(defmacro ebox-nested-scroll-test--with-buffer (&rest body)
"Run BODY with an isolated mounted surface and the Elisp renderer."
(declare (indent 0) (debug t))
`(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(let ((ebox-viewport-width 104) (ebox-viewport-height 30)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(with-temp-buffer
(unwind-protect (progn ,@body)
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer))))))))
(defun ebox-nested-scroll-test--input (&optional layout chrome peers)
"Build a scroll owner under LAYOUT with CHROME and PEERS other boxes."
(ebox-build
`(column :id "root" :width (px 104) :color "#204060"
(box :id "before" "before")
(,(or layout 'column)
,@(pcase layout
('row '(:width (px 104)))
('flex '(:width (px 104) :gap ((lh 0) (px 2))))
('grid '(:width (px 104) :grid-template-columns ((px 80) (px 20)))))
:background-color "#E0E8F0" :padding-left (px 2)
(box :id "scroll" :width (px 80) :height (lh 5) :overflow scroll
,@(when chrome '(:padding ((lh 1) (px 1)) :border-width (px 1)
:border-style solid :border-color "#456789"))
,@(cl-loop for number below 30
collect `(box :id ,(format "line-%d" number)
:help-echo ,(format "line %d" number)
:pointer hand
:hover-style (:background-color "#FFFFA0")
:keymap (keymap (13 . ignore))
,(format "line %02d" number))))
(box :id "sibling" :width (px 20) "sibling"))
,@(cl-loop for index below (or peers 0)
collect `(box :id ,(format "peer-%d" index) "peer"))
(box :id "after" "after"))))
(defun ebox-nested-scroll-test--region (id)
"Return ID's runtime region in the current mounted buffer."
(cdr (ebox-selector--region-target (ebox-region-resolve (current-buffer) id))))
(defun ebox-nested-scroll-test--mounts (&optional preserve-order-p)
"Return semantic IDs and their live mount ranges in the current buffer.
PRESERVE-ORDER-P retains attachment order for exact rollback checks."
(mapcar
(lambda (id)
(let* ((region (ebox-nested-scroll-test--region id))
(state (ebox--buffer-render-state (current-buffer)))
(object (gethash region (plist-get state :region-surface-object-table))))
(let ((ranges (mapcar (lambda (mount)
(cons (plist-get mount :start) (plist-get mount :end)))
(tp-object-mounts object))))
(cons id (if preserve-order-p ranges
(sort ranges
(lambda (left right)
(if (= (car left) (car right)) (< (cdr left) (cdr right))
(< (car left) (car right))))))))))
(append '("before" "scroll" "sibling" "after")
(cl-loop for index below 30 collect (format "line-%d" index)))))
(defun ebox-nested-scroll-test--parity ()
"Compare current paint, interactions and mounts with fresh committed input."
(let* ((state (ebox--buffer-render-state (current-buffer)))
(ebox-viewport-width (plist-get state :viewport-width))
(ebox-viewport-height (plist-get state :viewport-height))
(input (plist-get (ebox-surface-buffer-snapshot (current-buffer)) :input))
(actual (buffer-string))
(mounts (ebox-nested-scroll-test--mounts)))
(with-temp-buffer
(unwind-protect
(progn
(ebox-render-to-buffer (current-buffer) input)
(should (equal (substring-no-properties actual)
(buffer-substring-no-properties (point-min) (point-max))))
(dotimes (position (length actual))
(dolist (property '(face display help-echo mouse-face pointer keymap))
(should (equal (get-text-property position property actual)
(get-text-property (1+ position) property)))))
(should (equal mounts (ebox-nested-scroll-test--mounts))))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer)))))))
(ert-deftest ebox-nested-scroll-retained-fragments-own-newline-separators ()
"Retained paint ledgers cover separators with both neighboring owners."
(let* ((lines (list (propertize "before" 'ebox-content-owner 1)
(propertize "inside" 'ebox-content-owner 2)
(propertize "after" 'ebox-content-owner 3)))
(output (ebox-lines-join lines))
(retained (ebox-surface--scroll-fragment-data lines output))
(fresh (ebox-surface--rendered-fragments (copy-sequence output))))
(cl-labels ((roles-at (fragments position)
(plist-get
(cl-find-if (lambda (fragment)
(and (<= (plist-get fragment :start) position)
(< position (plist-get fragment :end))))
fragments)
:role-ids)))
(dotimes (position (length output))
(should (equal (roles-at retained position) (roles-at fresh position))))
(should (equal (roles-at retained 6) '((content-owner . 1) (content-owner . 2))))
(should (equal (roles-at retained 13) '((content-owner . 2) (content-owner . 3)))))
;; Leading/trailing unowned runs also depend on the adjacent line, so
;; these retain the complete scanner until that wider context is proved.
(dolist (boundary '(" unowned" "unowned "))
(let ((line (copy-sequence boundary)))
(put-text-property 1 (1- (length line)) 'ebox-content-owner 1 line)
(should-not (ebox-surface--scroll-line-fragment-template line))))))
(ert-deftest ebox-nested-scroll-cached-window-does-not-render-siblings ()
"One cached nested window avoids root renders and unrelated node work."
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-nested-scroll-test--input nil nil 200))
(let* ((region (ebox-nested-scroll-test--region "scroll"))
(owner (ebox--buffer-region-render-owner-node-id (current-buffer) region))
(child-region (ebox-nested-scroll-test--region "line-0"))
(child (ebox--buffer-region-render-owner-node (current-buffer) child-region))
(root-renders 0) (index-builds 0)
(render (symbol-function 'ebox-surface--render-candidate))
(layout (symbol-function 'ebox--render-layout))
(index (symbol-function 'ebox--runtime-index)))
(dolist (delta '(1 1 -1 -1))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf root-renders) (apply render arguments)))
((symbol-function 'ebox--runtime-index)
(lambda (&rest arguments)
(cl-incf index-builds) (apply index arguments)))
((symbol-function 'ebox--render-layout)
(lambda (node)
(should (equal (plist-get node :node-id) owner))
(funcall layout node))))
(should (= (ebox--surface-scroll-region-by
(current-buffer) region delta nil) delta)))
(should (eq child (ebox--buffer-region-render-owner-node
(current-buffer) child-region)))
(ebox-nested-scroll-test--parity))
(should (= root-renders 0))
(should (= index-builds 0))
(ebox-nested-scroll-test--parity))))
(ert-deftest ebox-nested-scroll-preserves-allocated-layout-and-chrome ()
"Column, Row, Flex and Grid owners retain paint, slots and scroll identities."
(dolist (layout '(column row flex grid))
(dolist (chrome '(nil t))
(ert-info ((format "layout=%S chrome=%S" layout chrome))
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer)
(ebox-nested-scroll-test--input layout chrome))
(let* ((region (ebox-nested-scroll-test--region "scroll"))
(old-state (ebox--buffer-render-state (current-buffer)))
(old-box (gethash region (plist-get old-state :region-box-table)))
(old-scroll (gethash region (plist-get old-state :scroll-state-table))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Nested allocation rendered root"))))
(should (= (ebox--surface-scroll-region-by
(current-buffer) region 1 nil) 1)))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(box (gethash region (plist-get state :region-box-table)))
(scroll (gethash region (plist-get state :scroll-state-table))))
(should-not (eq old-box box))
(should (eq box (plist-get scroll :box)))
(should (= (or (ebox-get old-box :scroll-offset) 0) 0))
(should (= (plist-get old-scroll :scroll-offset) 0))
(should (= (ebox-get box :scroll-offset) 1))
(should (= (plist-get scroll :scroll-offset) 1)))
(ebox-nested-scroll-test--parity)))))))
(ert-deftest ebox-nested-scroll-independent-owners-retain-their-caches ()
"Side-by-side scrolls keep independent offsets across content and resize."
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-nested-scroll-test--input 'row))
(ebox-region-update "sibling" :height '(lh 5) :overflow 'scroll
:content "sibling 0\nsibling 1\nsibling 2\nsibling 3\nsibling 4\nsibling 5\nsibling 6\nsibling 7")
(let ((left (ebox-nested-scroll-test--region "scroll"))
(right (ebox-nested-scroll-test--region "sibling")))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Independent scroll rendered root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) right 2 nil) 2)))
(ebox-nested-scroll-test--parity)
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll (gethash right (plist-get state :scroll-state-table)))
(lines (plist-get scroll :content-lines)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Independent scroll rendered root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) left 1 nil) 1)))
(let ((next (gethash right (plist-get (ebox--buffer-render-state
(current-buffer)) :scroll-state-table))))
(should (= (plist-get next :scroll-offset) 2))
(should (eq (plist-get next :content-lines) lines))))
(ebox-nested-scroll-test--parity)
(ebox-region-update "line-1" :content "UPDATED")
(should (string-match-p "UPDATED" (buffer-string)))
(ebox-nested-scroll-test--parity)
(ebox-rerender-buffer-with-context (current-buffer) 120 35)
(ebox-nested-scroll-test--parity)
(should (= (ebox--surface-scroll-region-by (current-buffer) left -1 nil) -1))
(ebox-nested-scroll-test--parity))))
(ert-deftest ebox-nested-scroll-rejection-restores-local-and-fallback-state ()
"TP rejection restores text, mounts and runtime on local and declined slots."
(dolist (fallback '(nil t))
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-nested-scroll-test--input))
;; Arbitrary inherited interactions deliberately require full projection.
(when fallback (ebox-region-update "root" :help-echo "enclosing action"))
(let* ((region (ebox-nested-scroll-test--region "scroll"))
(state (ebox--buffer-render-state (current-buffer)))
(scroll (gethash region (plist-get state :scroll-state-table)))
(box (plist-get scroll :box))
(before (buffer-string))
(mounts (ebox-nested-scroll-test--mounts t))
(revision (ebox-surface-buffer-revision (current-buffer)))
(root-renders 0)
(render (symbol-function 'ebox-surface--render-candidate)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf root-renders) (apply render arguments))))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state) (error "Reject nested scroll")))))
(should-error (ebox--surface-scroll-region-by
(current-buffer) region 1 nil))))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(should (equal mounts (ebox-nested-scroll-test--mounts t)))
(should (= (plist-get scroll :scroll-offset) 0))
(should (= (or (ebox-get box :scroll-offset) 0) 0))
(should (if fallback (> root-renders 0) (= root-renders 0)))
(should (= (ebox--surface-scroll-region-by (current-buffer) region 1 nil) 1))
(ebox-nested-scroll-test--parity)))))
(ert-deftest ebox-nested-scroll-updates-enclosing-cache-and-role-membership ()
"Inner motion repairs enclosing cache coordinates before outer scrolling."
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-nested-scroll-test--input nil nil 200))
(ebox-region-update "root" :height '(lh 15) :overflow 'scroll)
(let ((inner (ebox-nested-scroll-test--region "scroll"))
(outer (ebox-nested-scroll-test--region "root"))
(hidden (ebox-nested-scroll-test--region "line-0"))
(visible (ebox-nested-scroll-test--region "line-5")))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Inner scroll rendered enclosing root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1)))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll (gethash outer (plist-get state :scroll-state-table)))
(members (ebox--scroll-state-ensure-content-region-id-set scroll)))
(should (eq (plist-get scroll :box) (plist-get state :root-node)))
(should-not (gethash hidden members))
(should (gethash visible members))
(let ((bounds (plist-get scroll :region-line-bounds-index))
(fresh (ebox--scroll-build-region-line-bounds-index
(plist-get scroll :content-lines))))
(should (= (hash-table-count bounds) (hash-table-count fresh)))
(maphash (lambda (id range) (should (equal range (gethash id bounds)))) fresh)))
(ebox-nested-scroll-test--parity)
(ebox-region-update "line-5" :content "NEW VISIBLE")
(should (string-match-p "NEW VISIBLE" (buffer-string)))
(ebox-region-update "line-0" :content "NEW HIDDEN")
(should-not (string-match-p "NEW HIDDEN" (buffer-string)))
(ebox-nested-scroll-test--parity)
(should (= (ebox--surface-scroll-region-by (current-buffer) outer 2 nil) 2))
(ebox-nested-scroll-test--parity)
;; The inner allocation is partially clipped at this outer offset.
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1))
(ebox-nested-scroll-test--parity)
(should (= (ebox--surface-scroll-region-by (current-buffer) outer 10 nil) 10))
(let ((before (buffer-string)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Hidden inner scroll rendered root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1)))
(should (equal (substring-no-properties before) (buffer-string)))
(dotimes (position (length before))
(dolist (property '(face font-lock-face display help-echo keymap mouse-face pointer))
(should (equal (get-text-property position property before)
(get-text-property (1+ position) property)))))
(ebox-nested-scroll-test--parity))
(should (= (ebox--surface-scroll-region-by (current-buffer) outer -12 nil) -12))
(ebox-nested-scroll-test--parity))))
(ert-deftest ebox-nested-scroll-enclosing-cache-rejection-is-atomic ()
"Rejected inner publication preserves both scroll caches and source boxes."
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-nested-scroll-test--input))
(ebox-region-update "root" :height '(lh 15) :overflow 'scroll)
(let* ((inner (ebox-nested-scroll-test--region "scroll"))
(outer (ebox-nested-scroll-test--region "root"))
(state (ebox--buffer-render-state (current-buffer)))
(table (plist-get state :scroll-state-table))
(before (buffer-string))
(mounts (ebox-nested-scroll-test--mounts t))
(revision (ebox-surface-buffer-revision (current-buffer)))
(outer-state (gethash outer table))
(outer-lines (plist-get outer-state :content-lines)))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state) (error "Reject enclosing cache")))))
(should-error (ebox--surface-scroll-region-by (current-buffer) inner 1 nil)))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (eq outer-lines (plist-get (gethash outer table) :content-lines)))
(should (equal-including-properties before (buffer-string)))
(should (equal mounts (ebox-nested-scroll-test--mounts t)))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (= (plist-get (gethash inner table) :scroll-offset) 0))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1))
(ebox-nested-scroll-test--parity))))
(ert-deftest ebox-nested-scroll-enclosing-prefix-and-independent-owner-stay-current ()
"Ancestor prefix growth, materialization and resize retain inner state."
(ebox-nested-scroll-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :id "root" :width (px 104) :height (vh 50) :overflow scroll
(box :id "before" "before")
(column :id "scroll" :width (px 80) :height (vh 20) :overflow scroll
,@(cl-loop for n below 100 collect
`(box :id ,(format "line-%d" n) :help-echo ,(format "Action %d" n)
,(format "line %02d" n))))
(box :id "sibling" :width (px 20) :height (lh 6) :overflow scroll
"sibling 0\nsibling 1\nsibling 2\nsibling 3\nsibling 4\nsibling 5\nsibling 6\nsibling 7")
,@(cl-loop repeat 200 collect '(box "peer"))
(box :id "after" "after"))))
(let* ((inner (ebox-nested-scroll-test--region "scroll"))
(outer (ebox-nested-scroll-test--region "root"))
(other (ebox-nested-scroll-test--region "sibling"))
(old-outer (ebox--scroll-get-state outer))
(old-producer (plist-get old-outer :render-content-prefix))
(count (length (plist-get old-outer :content-lines))))
(should old-producer)
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Cached inner motion rendered root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 1) 1)))
(should-not (eq old-producer (plist-get (ebox--scroll-get-state outer) :render-content-prefix)))
(let ((lines (plist-get (ebox--scroll-get-state inner) :content-lines)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Independent inner motion rendered root"))))
(should (= (ebox--surface-scroll-region-by (current-buffer) other 1 1) 1)))
(should (eq lines (plist-get (ebox--scroll-get-state inner) :content-lines))))
(ebox-nested-scroll-test--parity)
(dotimes (_ 40)
(should (= (ebox--surface-scroll-region-by (current-buffer) outer 1 1) 1)))
(should (> (length (plist-get (ebox--scroll-get-state outer) :content-lines)) count))
(should (= (ebox--surface-scroll-region-by (current-buffer) outer -40 1) -40))
(ebox-nested-scroll-test--parity)
(ebox--scroll-state-materialize-lines outer (ebox--scroll-get-state outer))
(should (= (ebox--surface-scroll-region-by (current-buffer) outer 1 1) 1))
(should (= (ebox--surface-scroll-region-by (current-buffer) outer -1 1) -1))
(ebox-nested-scroll-test--parity)
(ebox-rerender-buffer-with-context (current-buffer) 120 40)
(ebox-nested-scroll-test--parity)
(should (= (plist-get (ebox--scroll-get-state inner) :scroll-offset) 1))
(should (= (plist-get (ebox--scroll-get-state other) :scroll-offset) 1))
(should (= (ebox--surface-scroll-region-by (current-buffer) inner -1 1) -1))
(ebox-nested-scroll-test--parity))))
(provide 'ebox-nested-scroll-tests)
;;; ebox-nested-scroll-tests.el ends here

View File

@ -0,0 +1,330 @@
;;; ebox-scroll-cold-tests.el --- Lazy scroll continuation boundaries -*- lexical-binding: t; -*-
;;; Commentary:
;; A real lazy-prefix miss must stage both continuation data and source-node
;; geometry before TP publishes text or client state. Rejected candidates
;; leave every published scroll owner ready to retry the same miss.
;; A published cache hit must also keep later prefix and materializer work
;; attached to the current owner instead of reviving its retired predecessor.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(defmacro ebox-scroll-cold-test--with-buffer (&rest body)
"Run BODY in an isolated mounted Elisp surface with no idle prefix work."
(declare (indent 0) (debug t))
`(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(let ((ebox-viewport-width 80) (ebox-viewport-height 8)
(ebox-native-buffer-scroll nil)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999)
(ebox--scroll-window-initial-lookahead-lines-override 0))
(with-temp-buffer
(unwind-protect (progn ,@body)
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer))))))))
(defun ebox-scroll-cold-test--input (boundary)
"Build a lazy owner at root, nested, or enclosing-scroll BOUNDARY."
(let ((target
`(column :id "target" :width (px 80) :height (vh 25) :overflow scroll
,@(cl-loop for index below 40
collect `(box :id ,(format "row-%d" index)
:height (lh 1)
:color "#204060"
:help-echo ,(format "Action %d" index)
:pointer hand
:keymap (keymap (13 . ignore))
,(format "row %02d" index))))))
(ebox-build
(if (eq boundary 'root) target
`(column :id "container" :width (px 80)
,@(when (eq boundary 'enclosing-scroll)
'(:height (vh 50) :overflow scroll))
(box :id "head" "head")
,target
(box :id "tail" :height (lh 8) "tail"))))))
(defun ebox-scroll-cold-test--assert-eq (label expected actual)
"Require identity of EXPECTED and ACTUAL, reporting only bounded LABEL."
(ert-info ((format "%s retains identity" label))
(let ((unchanged (eq expected actual))) (should unchanged))))
(defun ebox-scroll-cold-test--assert-equal (label expected actual)
"Compare EXPECTED and ACTUAL without printing runtime objects on failure."
(ert-info (label)
(let ((unchanged (equal expected actual))) (should unchanged))))
(defun ebox-scroll-cold-test--index-facts (state)
"Copy STATE's bounded region-index values and cache validity flags."
(cl-loop for key in '(:region-line-bounds-index :region-line-span-index
:rendered-region-line-span-index :content-region-id-set
:region-line-span-hints :region-line-span-index-deferred
:rendered-region-line-span-index-deferred
:content-lines-complete-p :lazy-scroll-prefix-dirty
:lazy-scroll-window-refresh-required :scroll-offset)
for value = (plist-get state key)
collect
(cons key
(if (hash-table-p value)
(let (entries)
(maphash (lambda (region spans)
(push (cons region (copy-tree spans)) entries))
value)
(sort entries
(lambda (left right)
(string< (prin1-to-string (car left))
(prin1-to-string (car right))))))
(copy-tree value)))))
(defun ebox-scroll-cold-test--node-facts (node)
"Copy NODE's source geometry and lazy-layout completion facts."
(cl-loop for key in '(:width :height :min-width :max-width :min-height :max-height
:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel
:scroll-offset :ebox-scroll-offset-controlled-p
:ebox-content-layout-complete-p :ebox-content-width-exact-p)
collect (list key (and (plist-member node key) t)
(copy-tree (plist-get node key)))))
(defun ebox-scroll-cold-test--snapshot ()
"Snapshot published identities plus bounded line, index, and node facts."
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(table (plist-get runtime :scroll-state-table))
scrolls nodes)
(maphash
(lambda (region state)
(push (list :region region :state state :box (plist-get state :box)
:producer (plist-get state :render-content-prefix)
:materializer (plist-get state :materialize-content-lines)
:content-head (plist-get state :content-lines)
:rendered-head (plist-get state :rendered-content-lines)
:content (mapcar #'tp-text-snapshot (plist-get state :content-lines))
:rendered (mapcar #'tp-text-snapshot (plist-get state :rendered-content-lines))
:index-facts (ebox-scroll-cold-test--index-facts state))
scrolls))
table)
(ebox-runtime-index-map
(lambda (node-id node)
(push (list node-id node (ebox-scroll-cold-test--node-facts node)) nodes))
(plist-get runtime :node-table))
(list :runtime runtime :root (plist-get runtime :root-node)
:source-index (plist-get runtime :source-index)
:region-box-registry ebox--region-box-table
:region-box-entries (copy-hash-table ebox--region-box-table)
:scrolls scrolls :nodes nodes :text (tp-text-snapshot (buffer-string))
:revision (ebox-surface-buffer-revision (current-buffer)))))
(defun ebox-scroll-cold-test--assert-lines (label expected actual)
"Require unchanged cached line counts and full properties for LABEL."
(ert-info ((format "%s line count" label))
(should (= (length expected) (length actual))))
(cl-loop for before in expected for after in actual for row from 0
do (ert-info ((format "%s row %d properties" label row))
(let ((unchanged (equal-including-properties before after)))
(should unchanged)))))
(defun ebox-scroll-cold-test--assert-snapshot (snapshot)
"Verify SNAPSHOT's complete published boundary after a rejected scroll."
(let ((runtime (ebox--buffer-render-state (current-buffer))))
(ebox-scroll-cold-test--assert-eq "Runtime" (plist-get snapshot :runtime) runtime)
(ebox-scroll-cold-test--assert-eq
"Root" (plist-get snapshot :root) (plist-get runtime :root-node))
(ebox-scroll-cold-test--assert-eq
"Source index" (plist-get snapshot :source-index) (plist-get runtime :source-index))
(ebox-scroll-cold-test--assert-eq
"Global region registry" (plist-get snapshot :region-box-registry) ebox--region-box-table)
(let ((entries (plist-get snapshot :region-box-entries)))
(should (= (hash-table-count entries) (hash-table-count ebox--region-box-table)))
(maphash (lambda (region box)
(ebox-scroll-cold-test--assert-eq
(format "Global region %s" region) box (gethash region ebox--region-box-table)))
entries))
(should (= (plist-get snapshot :revision)
(ebox-surface-buffer-revision (current-buffer))))
(let ((unchanged (equal-including-properties (plist-get snapshot :text) (buffer-string))))
(should unchanged))
(dolist (entry (plist-get snapshot :scrolls))
(let* ((region (plist-get entry :region))
(state (gethash region (plist-get runtime :scroll-state-table))))
(ebox-scroll-cold-test--assert-eq "Scroll owner" (plist-get entry :state) state)
(dolist (pair '((:box . :box) (:producer . :render-content-prefix)
(:materializer . :materialize-content-lines)
(:content-head . :content-lines) (:rendered-head . :rendered-content-lines)))
(ebox-scroll-cold-test--assert-eq
(format "Region %s %s" region (car pair))
(plist-get entry (car pair)) (plist-get state (cdr pair))))
(ebox-scroll-cold-test--assert-lines
(format "Region %s content" region)
(plist-get entry :content) (plist-get state :content-lines))
(ebox-scroll-cold-test--assert-lines
(format "Region %s rendered" region)
(plist-get entry :rendered) (plist-get state :rendered-content-lines))
(ebox-scroll-cold-test--assert-equal
(format "Region %s indexes and offset" region)
(plist-get entry :index-facts) (ebox-scroll-cold-test--index-facts state))))
(dolist (entry (plist-get snapshot :nodes))
(let ((node (ebox-runtime-index-get (car entry) (plist-get runtime :node-table))))
(ebox-scroll-cold-test--assert-eq "Published source child" (cadr entry) node)
(ebox-scroll-cold-test--assert-equal
(format "Source node %s geometry and completion" (car entry))
(nth 2 entry) (ebox-scroll-cold-test--node-facts node))))))
(defun ebox-scroll-cold-test--assert-fresh ()
"Compare committed text and interactions with independent source rendering."
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(input (plist-get (ebox-surface-buffer-snapshot (current-buffer)) :input))
(actual (buffer-string))
(ebox-viewport-width (plist-get runtime :viewport-width))
(ebox-viewport-height (plist-get runtime :viewport-height))
(render (symbol-function 'ebox--render-layout))
offsets first fresh)
(maphash
(lambda (_region state)
(push (cons (ebox-tree-node-id (plist-get runtime :source-index) (plist-get state :box))
(plist-get state :scroll-offset)) offsets))
(plist-get runtime :scroll-state-table))
(cl-letf (((symbol-function 'ebox--render-layout)
(lambda (root)
(unless first
(setq first t)
(cl-labels ((visit (node)
(when-let* ((entry (assoc (ebox-tree-node-id ebox--render-source-index node)
offsets)))
(ebox-put node :scroll-offset (cdr entry))
(ebox-put node :ebox-scroll-offset-controlled-p t))
(mapc #'visit (ebox-tree-node-children node))))
(visit root)))
(funcall render root))))
(setq fresh (ebox-render input)))
(should (equal (substring-no-properties actual) (substring-no-properties fresh)))
(should (= (length actual) (length fresh)))
(dotimes (position (length actual))
(dolist (property '(face font-lock-face display help-echo keymap mouse-face pointer))
(ebox-scroll-cold-test--assert-equal
(format "Fresh property %S at %d" property position)
(get-text-property position property actual)
(get-text-property position property fresh))))))
(defun ebox-scroll-cold-test--rollback (boundary failure-step)
"Reject a true lazy-prefix miss at BOUNDARY during FAILURE-STEP, then retry."
(ebox-scroll-cold-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-scroll-cold-test--input boundary))
(let* ((region (cdr (ebox-selector--region-target (ebox-region-resolve (current-buffer) "target"))))
(state (ebox--scroll-get-state region))
(count (length (plist-get state :content-lines)))
(height (plist-get state :content-height))
(delta (1+ (max 0 (- count height))))
(snapshot (ebox-scroll-cold-test--snapshot))
(ensure (symbol-function 'ebox--scroll-state-ensure-prefix-lines))
prefix-extended rejected)
(should (functionp (plist-get state :render-content-prefix)))
(should-not (plist-get state :content-lines-complete-p))
(should (= count 4))
(should (= (length (plist-get state :rendered-content-lines)) count))
(should (> (+ delta height) count))
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
(lambda (owner candidate required &rest options)
(let ((result (apply ensure owner candidate required options)))
(when (and (equal owner region)
(> (length (plist-get result :content-lines)) count))
(setq prefix-extended t))
result))))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step)
(setq rejected t)
(error "Reject cold scroll publication")))))
(should-error (ebox--surface-scroll-region-by (current-buffer) region delta 1))))
(should rejected)
(should prefix-extended)
(ebox-scroll-cold-test--assert-snapshot snapshot)
;; The mounted state still has its original producer; retrying the same
;; miss must produce the requested rows instead of skipping leaked work.
(should (= delta (ebox--surface-scroll-region-by (current-buffer) region delta 1)))
(should (= delta (plist-get (ebox--scroll-get-state region) :scroll-offset)))
(should (>= (length (plist-get (ebox--scroll-get-state region) :content-lines))
(+ delta height)))
(ebox-scroll-cold-test--assert-fresh)
(should (= (- delta) (ebox--surface-scroll-region-by (current-buffer) region (- delta) 1)))
(ebox-scroll-cold-test--assert-fresh))))
(ert-deftest ebox-scroll-cold-root-text-rejection ()
(ebox-scroll-cold-test--rollback 'root 'text))
(ert-deftest ebox-scroll-cold-root-client-state-rejection ()
(ebox-scroll-cold-test--rollback 'root 'client-state))
(ert-deftest ebox-scroll-cold-nested-text-rejection ()
(ebox-scroll-cold-test--rollback 'nested 'text))
(ert-deftest ebox-scroll-cold-nested-client-state-rejection ()
(ebox-scroll-cold-test--rollback 'nested 'client-state))
(ert-deftest ebox-scroll-cold-enclosing-scroll-text-rejection ()
(ebox-scroll-cold-test--rollback 'enclosing-scroll 'text))
(ert-deftest ebox-scroll-cold-enclosing-scroll-client-state-rejection ()
(ebox-scroll-cold-test--rollback 'enclosing-scroll 'client-state))
(defun ebox-scroll-cold-test--continue-after-hit (caller)
"Run CALLER after a cache hit and verify the continuation's published owner."
(ebox-scroll-cold-test--with-buffer
(ebox-render-to-buffer (current-buffer) (ebox-scroll-cold-test--input 'root))
(let* ((region (cdr (ebox-selector--region-target
(ebox-region-resolve (current-buffer) "target"))))
(initial (ebox--scroll-get-state region))
(count (length (plist-get initial :content-lines))))
(should (= count 4))
(should-not (plist-get initial :content-lines-complete-p))
(should (ebox--surface-scroll-cached-intent-p initial 1))
(should (= 1 (ebox--surface-scroll-region-by (current-buffer) region 1 1)))
(let* ((runtime (ebox--buffer-render-state (current-buffer)))
(owner-id (ebox--buffer-region-render-owner-node-id (current-buffer) region))
(owner (ebox-runtime-index-get owner-id (plist-get runtime :node-table)))
(state (ebox--scroll-get-state region)))
(should (= count (length (plist-get state :content-lines))))
(ebox-scroll-cold-test--assert-eq "Hit owner" owner (plist-get state :box))
(should (functionp (plist-get state :render-content-prefix)))
(should (functionp (plist-get state :materialize-content-lines)))
(pcase caller
('prefix
(setq state (ebox--scroll-state-ensure-prefix-lines region state (1+ count) t)))
('idle
(let ((ebox-scroll-lazy-idle-prefetch-lines 2)
(ebox-scroll-lazy-idle-prefetch-slice-lines 2))
;; Invoke the actual idle callback once without scheduling another
;; turn; its own bounded-prefix logic remains exercised.
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (&rest _) nil)))
(ebox--scroll-idle-prefetch region)))
(setq state (ebox--scroll-get-state region)))
('materializer
(setq state (ebox--scroll-state-materialize-lines region state))
(should (plist-get state :content-lines-complete-p))))
(should (> (length (plist-get state :content-lines)) count))
(should (= 1 (plist-get state :scroll-offset)))
(ebox-scroll-cold-test--assert-eq
"Continued owner" owner (plist-get state :box))
(ebox-scroll-cold-test--assert-eq
"Current owner after continuation" owner
(ebox-runtime-index-get owner-id
(plist-get (ebox--buffer-render-state (current-buffer)) :node-table)))
(ebox-scroll-cold-test--assert-fresh)
(should (= -1 (ebox--surface-scroll-region-by (current-buffer) region -1 1)))
(ebox-scroll-cold-test--assert-fresh)))))
(ert-deftest ebox-scroll-cold-root-hit-prefix-keeps-current-owner ()
(ebox-scroll-cold-test--continue-after-hit 'prefix))
(ert-deftest ebox-scroll-cold-root-hit-idle-keeps-current-owner ()
(ebox-scroll-cold-test--continue-after-hit 'idle))
(ert-deftest ebox-scroll-cold-root-hit-materializer-keeps-current-owner ()
(ebox-scroll-cold-test--continue-after-hit 'materializer))
(provide 'ebox-scroll-cold-tests)
;;; ebox-scroll-cold-tests.el ends here

View File

@ -0,0 +1,201 @@
;;; ebox-scroll-slice-tests.el --- Retained visible scroll slices -*- lexical-binding: t; -*-
;;; Commentary:
;; Complete line caches index stable cons handles, preserving current row
;; strings through equal-length rewrites and isolated publication rollback.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(defun ebox-scroll-slice-test--lines (count)
"Return COUNT independently allocated deterministic source lines."
(cl-loop for index below count collect (format "Row %05d" index)))
(defun ebox-scroll-slice-test--state (lines &optional incomplete)
"Return a scroll state containing LINES, or a lazy prefix if INCOMPLETE."
(list :content-height 3 :scroll-offset 0
:content-lines lines :rendered-content-lines lines
:content-lines-complete-p (not incomplete)
:render-content-prefix (and incomplete #'ignore)))
(ert-deftest ebox-scroll-slice-complete-window-avoids-prefix-walks ()
"Cached windows index only visible rows even at a distant document offset."
(let* ((lines (ebox-scroll-slice-test--lines 10000))
(state (ebox-scroll-slice-test--state lines))
(expected (seq-subseq lines 9000 9003))
(native-comp-enable-subr-trampolines nil)
(length-function (symbol-function 'length))
(subseq-function (symbol-function 'seq-subseq))
(full-lengths 0) (list-slices 0))
(ebox--scroll-state-rendered-visible-window state)
(should (vectorp (plist-get state :rendered-content-line-cells)))
(plist-put state :scroll-offset 9000)
(cl-letf (((symbol-function 'length)
(lambda (value)
(when (eq value lines) (cl-incf full-lengths))
(funcall length-function value)))
((symbol-function 'seq-subseq)
(lambda (value start &optional end)
(when (eq value lines) (cl-incf list-slices))
(funcall subseq-function value start end))))
(let ((visible (ebox--scroll-state-rendered-visible-window state)))
(should (= (car visible) 9000))
(should (cl-every #'eq (cadr visible) expected))))
(should (= full-lengths 0))
(should (= list-slices 0))))
(ert-deftest ebox-scroll-slice-index-observes-current-cons-cars ()
"Same-spine row rewrites retain exact strings, opaque callbacks and hover groups."
(let* ((first (lambda () 'first))
(second (lambda () 'second))
(hover (list :background "blue"))
(line (propertize "before" 'help-echo first 'mouse-face hover))
(replacement (propertize "after" 'help-echo second 'mouse-face hover))
(lines (list line (propertize "tail" 'mouse-face (copy-sequence hover))))
(state (ebox-scroll-slice-test--state lines)))
(ebox--scroll-state-rendered-visible-window state)
(let ((index (plist-get state :rendered-content-line-cells)))
(ebox--line-list-rewrite! lines 0 (list replacement))
(let ((visible (cadr (ebox--scroll-state-rendered-visible-window state))))
(should (eq index (plist-get state :rendered-content-line-cells)))
(should (eq replacement (car visible)))
(should (eq second (get-text-property 0 'help-echo (car visible))))
(should (eq hover (get-text-property 0 'mouse-face (car visible))))
(should-not (eq hover (get-text-property 0 'mouse-face (cadr visible))))))))
(ert-deftest ebox-scroll-slice-extension-and-replacement-refresh-index ()
"Tail extension and replacement heads cannot reuse stale positional indexes."
(let* ((lines (ebox-scroll-slice-test--lines 4))
(state (ebox-scroll-slice-test--state lines)))
(ebox--scroll-state-rendered-visible-window state)
(let ((first-index (plist-get state :rendered-content-line-cells)))
(ebox--line-list-append! lines (last lines) (list "new fourth" "new fifth"))
(plist-put state :scroll-offset 3)
(should (equal (cadr (ebox--scroll-state-rendered-visible-window state))
'("Row 00003" "new fourth" "new fifth")))
(should-not (eq first-index (plist-get state :rendered-content-line-cells))))
(let* ((old-index (plist-get state :rendered-content-line-cells))
(replacement (copy-sequence lines))
(ebox--defer-scroll-content-index t))
(setcar (nthcdr 3 replacement) "replacement")
(setq state (ebox--scroll-state-set-lines state replacement replacement))
(should-not (eq old-index (plist-get state :rendered-content-line-cells)))
(should (equal (car (cadr (ebox--scroll-state-rendered-visible-window state)))
"replacement"))
(let ((index (plist-get state :rendered-content-line-cells)))
(setq state (ebox--scroll-state-set-lines state replacement replacement))
(should (eq index (plist-get state :rendered-content-line-cells)))))))
(ert-deftest ebox-scroll-slice-lazy-prefix-remains-unindexed ()
"Incrementally extended prefixes avoid rebuilding a complete vector each slice."
(let* ((lines (ebox-scroll-slice-test--lines 4))
(state (ebox-scroll-slice-test--state lines t))
(ebox--defer-scroll-content-index t))
(ebox--scroll-state-rendered-visible-window state)
(should-not (plist-get state :rendered-content-line-cells))
(ebox--line-list-rewrite! lines 3 '("rewritten tail"))
(ebox--line-list-append! lines (last lines) '("appended"))
(setq state (ebox--scroll-state-set-lines state lines lines))
(plist-put state :scroll-offset 2)
(should (equal (cadr (ebox--scroll-state-rendered-visible-window state))
'("Row 00002" "rewritten tail" "appended")))
(should-not (plist-get state :rendered-content-line-cells))
(plist-put state :content-lines-complete-p t)
(ebox--scroll-state-rendered-visible-window state)
(should (vectorp (plist-get state :rendered-content-line-cells)))))
(ert-deftest ebox-scroll-slice-native-empty-and-short-windows ()
"Native windows take precedence; empty, short and clamped windows stay exact."
(dolist (count '(0 1 2 3 4))
(let* ((lines (ebox-scroll-slice-test--lines count))
(state (ebox-scroll-slice-test--state lines)))
(dolist (offset '(-4 0 1 20))
(plist-put state :scroll-offset offset)
(if lines
(let* ((start (max 0 (min (max 0 (- count 3)) offset)))
(expected (list start (seq-subseq lines start (min count (+ start 3))))))
(should (equal (ebox--scroll-state-rendered-visible-window state)
expected)))
(should-not (ebox--scroll-state-rendered-visible-window state))))))
(let* ((state (ebox-scroll-slice-test--state '("ordinary")))
(native (list "native one" "native two" "native three")))
(plist-put state :scroll-offset 100)
(plist-put state :native-reflow-visible-offset 100)
(plist-put state :native-reflow-visible-lines native)
(let ((visible (ebox--scroll-state-rendered-visible-window state)))
(should (= (car visible) 100))
(should (eq (cadr visible) native))
(should-not (plist-get state :rendered-content-line-cells)))))
(ert-deftest ebox-scroll-slice-template-discards-derived-index ()
"Render-cache templates retain source lines without retaining the derived vector."
(let* ((lines (ebox-scroll-slice-test--lines 5))
(state (ebox-scroll-slice-test--state lines)))
(ebox--scroll-state-rendered-visible-window state)
(let ((template (ebox--scroll-cache-state-template state)))
(should (eq lines (plist-get template :rendered-content-lines)))
(should-not (plist-member template :rendered-content-line-cells)))))
(ert-deftest ebox-scroll-slice-candidate-replacement-keeps-prior-index ()
"A copied candidate state can replace its lines without mutating the old index."
(let* ((lines (ebox-scroll-slice-test--lines 8))
(state (ebox-scroll-slice-test--state lines))
(ebox--defer-scroll-content-index t))
(ebox--scroll-state-rendered-visible-window state)
(let* ((old-index (plist-get state :rendered-content-line-cells))
(candidate (copy-sequence state))
(candidate-lines (copy-sequence lines)))
(setcar candidate-lines "candidate only")
(setq candidate (ebox--scroll-state-set-lines candidate candidate-lines candidate-lines))
(should (equal (car (cadr (ebox--scroll-state-rendered-visible-window candidate)))
"candidate only"))
(should (eq old-index (plist-get state :rendered-content-line-cells)))
(should (eq lines (aref old-index 0)))
(should (eq (car lines) (car (cadr (ebox--scroll-state-rendered-visible-window state))))))))
(ert-deftest ebox-scroll-slice-publication-rollback-retains-index-generation ()
"Failed publication leaves old row handles usable and the next scroll succeeds."
(let ((ebox-viewport-width 104) (ebox-viewport-height 5)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p) (lambda () nil)))
(with-temp-buffer
(unwind-protect
(progn
(ebox-render-to-buffer
(current-buffer)
(ebox-build `(box :id "scroll" :width (vw 100) :height (vh 100)
:overflow scroll
,(string-join (ebox-scroll-slice-test--lines 100) "\n"))))
(let* ((buffer (current-buffer))
(region (cdr (ebox-selector--region-target
(ebox-region-resolve buffer "scroll")))))
(ebox--scroll-state-materialize-lines region (ebox-scroll-state region))
(ebox--surface-scroll-to-offset buffer region 1)
(ebox--surface-scroll-to-offset buffer region 0)
(let* ((state (ebox-scroll-state region))
(index (plist-get state :rendered-content-line-cells))
(before (buffer-string))
(revision (ebox-surface-buffer-revision buffer)))
(should (vectorp index))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject indexed scroll"))))
(should-error (ebox--surface-scroll-to-offset buffer region 1)))
(should (eq state (ebox-scroll-state region)))
(should (eq index (plist-get state :rendered-content-line-cells)))
(should (equal-including-properties before (buffer-string)))
(should (= revision (ebox-surface-buffer-revision buffer)))
(ebox--surface-scroll-to-offset buffer region 1)
(should (eq index (plist-get (ebox-scroll-state region)
:rendered-content-line-cells)))
(should (= 1 (plist-get (ebox-scroll-state region) :scroll-offset)))
(should (string-prefix-p "Row 00001" (buffer-string))))))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer))))))))
(provide 'ebox-scroll-slice-tests)
;;; ebox-scroll-slice-tests.el ends here