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
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
@ -34,6 +34,24 @@ 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:
$(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
For a chrome-free root scroll owner whose cached rendered lines cover the
current visible window (the lazy prefix may remain incomplete),
the scroll patch joins the staged visible window directly instead of entering
the full candidate layout. It reuses the published cascade, viewport axes,
object maps, and descendant `region-box-table`; it only advances the scroll
offset and installs the root `ebox-scroll-window` property. The proof is
conservative: a cache miss, chrome, active style dependency, topology change,
or incomplete window falls back to the ordinary retained path. Scroll
publication still goes through TP and the Ebox participant, so a failure after
TP client-state publication restores text, offsets, indexes, objects, mounts,
and report together.
Cached scroll intents reuse the published cascade, viewport axes and unchanged
runtime indexes when the visible window and display context are proven stable.
Chrome-free roots join cached rendered lines directly; layered or chrome-bearing
roots may reuse their complete composed content and render only the wrapper.
Pure root offset changes preserve already composed portal and anchor content.
Style, placement, content and display-context changes must invalidate that proof.
A nested owner can replace its viewport within a proven unchanged allocation,
without rendering unrelated siblings. With one unchanged root scroll ancestor,
the update must also patch that ancestor's retained content, ownership membership
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

View File

@ -47,14 +47,28 @@ span、owner-scoped、formatting-context 或 mixed 局部输出被拒绝时pr
## 保留式 scroll window
对于 chrome-free、缓存 rendered lines 已覆盖当前可见窗口的 root scroll owner
lazy prefix 可以仍未完整 materializescroll patch 会
直接从 staged visible window 拼接输出,不再进入完整 candidate layout。它复用已
发布的 cascade、viewport axes、object maps 和 descendant `region-box-table`,只推进
scroll offset 并安装 root `ebox-scroll-window` 属性。证明保持保守cache miss、
chrome、active style dependency、topology 变化或窗口不完整时,回退到普通 retained
路径。Scroll publication 仍经过 TP 与 Ebox participant即使 TP client-state 发布
之后失败也会一起恢复文本、offset、index、object、mount 和 report。
缓存滚动在可见窗口和 display context 的稳定性得到证明后,复用已发布的 cascade、
viewport axes 与未变化的 runtime index。Chrome-free root 直接拼接 rendered lines
layered 或带 chrome 的 root 可以复用完整合成内容,只渲染 wrapper。纯 root offset
变化保留已经合成的 portal 和 anchor 内容style、placement、content 或 display
context 变化必须使该证明失效。
Nested owner 在 allocation 不变且通过证明时,可以只替换自己的 viewport避免渲染
无关兄弟节点。若存在一个 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 与坐标

View File

@ -218,10 +218,14 @@ no command binding by itself.
```
`: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`
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
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
@ -397,6 +401,7 @@ They do not publish by themselves.
| `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-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-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`. |
@ -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
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
children and never call `ebox-child-range`; Range addresses exist only for
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-child-range`,
`ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update`,
`ebox-call-with-update-batch`,
`ebox-host-ref-bounds`, `ebox-host-ref-position`.
- Selectors: `ebox-selector-parse`, `ebox-selector-match-node-p`,
`ebox-selector-query-all`, `ebox-selector-query-buffer`,

View File

@ -185,9 +185,12 @@ Emacs 设置及窗口系统;单独设置指针形状不会绑定命令。
```
`:activate` 接收零参数函数,将其绑定到 `RET`、`[return]`、`SPC` 和 `[mouse-1]`
主按钮双击、三击的每次释放也各激活一次。主按钮按下和拖动事件,包括其双击、三击
变体,默认不执行操作,避免触发原生文本选择。
`:bindings` 接收 alistkey 是按键描述字符串或事件向量value 是零参数函数。
两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与激活键
重叠的绑定。辅助函数返回原生 Emacs keymap回调保留为函数直到对应命令触发时才调用。
两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与上述四个
基础激活键重叠的绑定。显式绑定可以覆盖连击、按下和拖动事件的默认行为。
辅助函数返回原生 Emacs keymap回调保留为函数直到对应命令触发时才调用。
鼠标事件会自动从事件取得窗口,并在其 buffer 中运行回调,不选择窗口或移动 point。
键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。
@ -343,6 +346,7 @@ observer 为 nil 时Ebox 不创建 observation context不取 timestamp/GC
| `ebox-region-ids` | 按文档顺序返回当前底层 region id。 |
| `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 |
| `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-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 |
| `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 |
@ -369,6 +373,24 @@ candidate 被 commit 封存后不能复用。
之后直接修改原 map 不会更新已挂载的 surface应显式替换 `:keymap` 来发布新绑定。
清除该属性只移除节点 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不调用
`ebox-child-range`Range address 只服务已经拥有 semantic child range 与 candidate
update 的框架。
@ -429,6 +451,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它
`ebox-candidate-replace-host-ref`、`ebox-candidate-patch-host-paint`、
`ebox-child-range`
`ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update`、
`ebox-call-with-update-batch`
`ebox-host-ref-bounds`、`ebox-host-ref-position`
- Selector`ebox-selector-parse`、`ebox-selector-match-node-p`、
`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`:
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
buffer for mouse callbacks, so a callback can directly update a semantic ID
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
`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
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
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`
`:activate` 的回调不带参数,处理 `RET`、`[return]`、`SPC` 和 `[mouse-1]`
快速双击、三击的每次释放也各激活一次;主按钮按下和拖动默认不执行操作,保留 point
和选区,避免触发原生的选词或整行选择。
`:bindings` 接受 alist按键描述字符串或事件向量对应零参数回调。
辅助函数让鼠标回调在事件窗口的 buffer 中运行,因此回调可以直接按语义 ID 更新:
`(ebox-region-update "action" :help-echo "Activated")`
显式绑定可以覆盖连击、按下和拖动事件的默认行为。
使用键盘绑定时,先把 point 移到交互范围内。可选命令 `ebox-next-interaction`
`ebox-previous-interaction` 在当前渲染了可用 keymap 的声明节点之间移动。每个 owner
@ -468,6 +471,23 @@ Ebox 对 keymap 创建快照;发布新绑定时应替换 `:keymap`,而不是
`ebox-selector-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id
只是诊断用渲染 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 与滚动
内容超过有限高度时,`:overflow scroll` 会创建内部 scroll window。键盘滚动以

View File

@ -35,7 +35,11 @@
Retain the source properties and nested display values by identity."
(let* ((result (substring line start end))
(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
0 (length result) 'display
(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-subtree-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
(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.
CACHE-PROBE may supply a prior exact miss from an accelerator boundary."
(if (and ebox-incremental--render-portals-p
(fboundp 'ebox-layer-portals-p)
(ebox-layer-portals-p node))
(fboundp 'ebox-layer-visible-portals-p)
(ebox-layer-visible-portals-p node))
;; Cached strings do not replay the candidate-local portal collector.
(ebox--render-node node)
(if-let* ((prepared (and force
@ -1796,8 +1798,10 @@ separately for every role property."
(setcdr range (max (cdr range) end))
(puthash line-number (cons start end) line-map))))
(defun ebox--build-region-line-index ()
"Return a per-line index of ebox region ownership in the current buffer."
(defun ebox--build-region-line-index (&optional include-owner-stacks-p)
"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))
(line-number 0)
lines)
@ -1810,7 +1814,9 @@ separately for every role property."
(while (< pos line-end)
(let* ((role-ids (ebox--pos-region-role-ids pos))
(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
pos line-end)))
(when ids
@ -3639,15 +3645,18 @@ running expensive span and snapshot checks."
(not (equal source-node-id node-id))
(eq dirty-kind 'geometry)
(memq :content (plist-get entry :changed-keys))
(or (ebox-incremental--content-independent-allocation-p
(ebox--buffer-render-state buffer) node-id)
(cl-some
(lambda (signature)
(let ((content (plist-get signature :content)))
(and (stringp content) (string-match-p "\n" content))))
(list (plist-get entry :old-signature)
(plist-get entry :new-signature)))
(plist-get entry :new-signature))))
;; Canonical Text owns the content identity; its Box owns the
;; allocated slot. Ask the same slot proof about that owner
;; before promoting a text-length change to the parent layout.
;; allocated slot. The same proof handles both one-line text
;; and bounded multiline content; source identity alone must
;; not promote a local text change to the parent layout.
(ebox-incremental--content-slot-stable-p
buffer (plist-put (copy-sequence entry) :node-id node-id)
ebox-incremental--candidate-state-for-slot-proof))
@ -8637,6 +8646,7 @@ the inner host's output."
(parents (plist-get candidate-state :parent-table))
(ebox-incremental--buffer-render-state-override
(cons buffer candidate-state))
(ebox-incremental--candidate-state-for-slot-proof candidate-state)
affected operations)
(dolist (dirty dirty-set)
(unless (eq (plist-get dirty :dirty-kind) 'metadata)
@ -8662,12 +8672,16 @@ the inner host's output."
:layer-recompose-p t))
(ebox-incremental--declarative-tentative-op buffer dirty))
operations))))
;; A root portal can cover or anchor to nodes outside its local host.
;; Their updates therefore belong to the root composition as well.
;; A visible root portal can cover or anchor to nodes outside its host.
;; Test both snapshots so revealing and hiding keep root composition,
;; while hidden portals do not widen unrelated local updates.
(when (or affected
(and operations (plist-get old-state :root-portals-p)))
(if (or (plist-get old-state :root-portals-p)
(ebox-layer-portals-p (plist-get candidate-state :root-node)))
(if (or (and (plist-get old-state :root-portals-p)
(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
'owner-rerender
(plist-get (plist-get candidate-state :root-node) :node-id)
@ -8675,24 +8689,64 @@ the inner host's output."
:layer-recompose-p t))
(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
(buffer old-state prepared candidate-state owner-plan path-copied-p)
"Prove the stable outer allocation of one recomposed layer owner.
Compare BUFFER's OLD-STATE with CANDIDATE-STATE using PREPARED dirty facts
and OWNER-PLAN. PATH-COPIED-P requires private nodes inside that owner.
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)
(plist-get (car owner-plan) :layer-recompose-p))
(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
owner-id (plist-get candidate-state :node-table)))
(snapshot (ebox-incremental--cached-layout-snapshot-details
buffer owner-id))
(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
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
old-state candidate-state)
(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)
(ebox--spans-contiguous-lines-p spans))))
(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)
:layer-recompose-p t :ancestor-slot-signature ancestors
:layout-snapshot-detail-generation
@ -9667,6 +9725,11 @@ metadata; this predicate only authorizes the local attempt."
(and (memq :content changed-keys)
(ebox-incremental--content-slot-stable-p
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
(and (not (memq :content changed-keys))
(cl-some
@ -9711,7 +9774,8 @@ metadata; this predicate only authorizes the local attempt."
buffer old-state candidate-state owner-id))))
(allocated-width
(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)
(car content-slot-evidence))
(car ancestor-slot-signature)))
@ -9765,10 +9829,13 @@ metadata; this predicate only authorizes the local attempt."
old-state candidate-state (list dirty))))
(ebox-incremental--owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state owner-id)
;; Admit the new scroll coexistence case only for an exact
;; content extent. Length or width changes can alter an
;; ancestor's line filler outside the local owner's spans.
;; An allocated Box owns its changing line filler. Other
;; owners beside retained scroll content still need an exact
;; 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))
allocated-content-box-p
(let ((before (plist-get (plist-get dirty :old-signature)
:content))
(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
:variable-content-p
(and snapshot
;; With retained scroll siblings, the final rendered
;; footprint must remain exact even when raw content has
;; equal width under the current buffer's default font.
(null (plist-get old-state :scroll-region-ids))
;; Only a content-independent Box owns all filler affected
;; by its changing text beside retained scroll siblings.
(or (null (plist-get old-state :scroll-region-ids))
allocated-content-box-p)
(memq :content changed-keys)
(stringp
(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)
(plist-get span-proof :allocated-width))
: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))
(and (eq projection-kind 'mixed-owner-reflow)
(eq (plist-get (plist-get prepared
@ -10783,20 +10850,23 @@ role, and outside-complement compatibility before publication."
base)
(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.
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
proofs there rather than treating invisible owners as zero-sized. Nested
scrolls, non-content changes and uncached/partial owners retain their ordinary
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))
(region (and (= (length regions) 1) (car regions)))
(scroll (and region (gethash region ebox--scroll-global-state)))
(root (plist-get old-state :root-node))
(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
(plist-get prepared :scroll-state-table))))
(root (plist-get old-state :root-node))
(dirty-set (plist-get prepared :dirty-set))
(lines (plist-get scroll :content-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 next-scroll :box)
(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)
(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 scroll :lazy-scroll-prefix-dirty))
(equal (plist-get scroll :scroll-offset)
@ -10829,12 +10914,24 @@ the visible replacement independently before publishing either one."
(ebox-incremental--candidate-base-state
(cons (current-buffer) base))
(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))
(plan (ebox-incremental--layout-owner-plan
(current-buffer) base candidate dirty-set))
(proof (ebox-incremental--span-patch-projection-proof
(current-buffer) base input candidate plan)))
(plan (if scroll-owner-id
(list (ebox--patch-op 'span-patch scroll-owner-id :dirty dirty-set))
(ebox-incremental--layout-owner-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
(plist-put proof :scroll-content-proof
(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))
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
(buffer old-state next-root isolated-candidate-p
&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-put candidate-state :selector-index-stale-p t)))
(layer-owner-plan
(unless (and scroll-transaction-p
(plist-get old-state :layered-p)
(ebox-incremental--layer-root-scroll-p
old-state candidate-state prepared scroll-owner-id))
(ebox-incremental--layer-owner-plan
buffer old-state candidate-state
(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)))))))
:changed-keys '(:scroll-offset))))))))
(formatting-proof
(and (not layer-owner-plan) (not scroll-transaction-p)
(not (cl-some
@ -10952,13 +11079,15 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-incremental--paint-projection-p
buffer old-state prepared candidate-state owner-plan))
(span-proof
(unless (or paint-p mixed-proof
(and skip-span-proof-p (not layer-owner-plan)))
(if (and scroll-transaction-p skip-span-proof-p (not layer-owner-plan))
(ebox-incremental--scroll-content-span-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
path-copied-p)
(ebox-incremental--scroll-content-span-proof
buffer old-state prepared candidate-state))))
buffer old-state prepared candidate-state)))))
(projection-kind
(cond
(mixed-proof 'mixed-owner-reflow)

View File

@ -60,9 +60,11 @@
(defun ebox-keymap-create (&rest options)
"Create an ordinary native keymap from OPTIONS.
`:activate' is an optional zero-argument callback for RET, return, SPC and
the primary mouse button. Nil omits these activation bindings. `:bindings'
is an alist of string key descriptions or key vectors paired with
zero-argument callbacks.
the primary mouse button. Repeated clicks each activate once; primary-button
press and drag events do nothing, so they do not start native text selection.
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
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.
@ -104,7 +106,20 @@ This function does not supply focus navigation, state or event propagation."
(error "Ebox keymap binding overlaps an existing key: %s"
(key-description key)))
(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))
(defun ebox-interaction--navigable-keymap-p (map)

View File

@ -35,6 +35,10 @@
(defvar ebox-layer--portals nil
"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
"Whether the current candidate contains layered layout.
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)
(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)
"Whether LINE at POSITION visibly belongs to REGION, excluding margins."
(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)))
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)
"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
(let ((start 0) (limit (length line)))
(while (< start limit)
(let ((end (next-property-change start line limit)))
(when (ebox-layer--owned-p line start region)
(let ((x (ebox--substring-pixel-width line 0 start))
(right (ebox--substring-pixel-width line 0 end)))
(let ((x (ebox-layer--prefix-width line start))
(right (ebox-layer--prefix-width line end)))
(when (> right x)
;; Keep the full row as source so shaping and shared
;; 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))
(base (funcall renderer flow)))
(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)
(if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0)))
(flow-height (length lines))
@ -335,7 +382,8 @@ Only candidate-local lists of visible paint records are sorted."
(plist-get piece :end))) records)))))
(unless (eq host ebox-layer--render-root)
(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)))
(setq absolute (cl-remove-if (lambda (child)
(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))))))
(if (or (ebox-layer-host-p box)
(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)
(funcall render-flow children)))))))
(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)
"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-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
((eq (plist-get node :ebox-kind) 'text)
(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 'stack) (ebox--render-stack node))
((eq type 'flex) (ebox--render-flex node))

View File

@ -253,7 +253,8 @@
ebox--render-string-pixel-width-cache
ebox--rendered-uniform-width-table ebox--rendered-intrinsic-size-table
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-ring
ebox--viewport-dependent-node-ids-cache
@ -304,6 +305,9 @@ rather than relying on an implicit default.")
(:symbol ebox-layer--portals
:reason dynamically-bound-render-collection
: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
:reason numeric-eviction-cursor
: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))
(declare-function ebox--scroll-state-retained-window-ready-p
"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
"ebox" (box))
(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)
(let ((fragments (ebox-surface--rendered-fragments line)))
(when (and fragments
(plist-get (car fragments) :paint-role-ids)
(plist-get (car (last fragments)) :paint-role-ids)
(cl-every (lambda (fragment)
(plist-get fragment :role-ids))
fragments))
(puthash line fragments ebox-surface--scroll-line-fragment-cache)
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)
"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
single joined RENDERED string. The text and role values remain candidate
owned; no per-fragment substring is allocated."
(cl-block ebox-surface--scroll-fragment-data
(let ((offset 0)
(line-index 0)
result)
(dolist (line lines)
(let ((template (ebox-surface--scroll-line-fragment-template line)))
(unless template
(cl-return-from ebox-surface--scroll-fragment-data nil))
(dolist (fragment template)
(let (result)
(when (ebox-surface--map-scroll-fragments
lines
(lambda (fragment offset line-index)
(let ((copy (copy-sequence fragment)))
(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))))
(defun ebox-surface--scroll-lines-owned-p (lines)
@ -1896,8 +1932,10 @@ the projection roots a second time."
(ebox-surface--native-patch-result
context projection state output node-objects region-objects))
(and (or (null property-contributions) retained-owned-ranges)
(memq projection-kind
(or (memq projection-kind
'(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
context projection state output previous-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)
(plist-get state :span-patch-fragment-data))))
(rebased
;; Layer composition can replace visible owners without
;; moving a single character. Coordinates alone cannot
;; retain its mounts; rebuild them from the new role runs.
(and (not (plist-get state :layer-recompose-p))
(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)))))
previous-owned-ranges patches (length rendered))))))
(batch
(ebox-surface--coordinate-batch
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))
(producer-keys
'(:content-lines :rendered-content-lines
:rendered-content-line-cells
:content-lines-complete-p :render-content-prefix
:materialize-content-lines :region-line-bounds-index
: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)))
(defun ebox-surface--retained-external-owner-suffix
(buffer spans region-ids)
"Return one common retained content-owner suffix outside REGION-IDS."
(buffer spans region-ids &optional allow-empty-p)
"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)
(setq valid t)
(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))
(setq common external seen t)))
(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)
"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)))
(ebox--flex-copy-node-for-size node 'row width nil nil)))
((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))))
(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)))))
(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
(buffer previous-state state owner-id spans replacement allocated-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)))
(new (ebox-runtime-index-get new-id (plist-get state :node-table))))
(and (equal old-id new-id)
(if role-owned-lines-p
;; The canonical candidate may replace an
;; anonymous source handle while retaining its
;; runtime slot. This mode rebuilds fragment
(if (or role-owned-lines-p variable-content-p)
;; The canonical candidate may replace a
;; source handle while retaining its
;; runtime slot. These modes rebuild fragment
;; metadata; verify that slot's full identity
;; topology instead of retaining the old handle.
(and (eq (plist-get old :ebox-type)
@ -3975,7 +4048,7 @@ Proof mismatches return nil; unexpected rendering errors propagate."
line)
(setq position end)))
(push line result))))))
(and valid (mapconcat #'identity (nreverse result) "\n")))))))
(and valid (ebox-lines-join (nreverse result))))))))
(defun ebox-surface--owner-patch-candidate
(buffer state owner-id allocated-width
@ -4094,31 +4167,113 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(plist-get (plist-get details :footprint)
: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)
"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))
(old-snapshot (plist-get proof :snapshot))
(spans (plist-get old-snapshot :buffer-spans))
(node (ebox-runtime-index-get owner-id (plist-get state :node-table)))
(render-node
(if (and node (numberp (plist-get proof :allocated-width)))
(cond
((or (plist-get proof :layer-recompose-p)
(plist-get proof :nested-scroll-patch-p))
(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))
node))
:width (plist-get proof :allocated-width)))
(t node)))
(rendered
(and 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
(and rendered
(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
(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
(and rendered
(ebox-buffer--rendered-in-existing-slots
spans rendered nil t)))
(or (ebox-buffer--rendered-in-existing-slots
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)))
(new-spans
(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
(plist-get old-snapshot :parent-slot-signature)
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
(and replacement
(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))
(owner-proof (car (plist-get state :owner-scoped-proofs)))
(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)
(when (and old-scroll next-scroll
(eq lines (plist-get old-scroll :content-lines))
@ -4279,6 +4438,11 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(ebox-incremental--candidate-base-state
(cons (current-buffer) base)))
(setq cached-candidate
(if nested-scroll-p
(ebox-surface--nested-scroll-patch-candidate
(current-buffer) base candidate
(plist-put (copy-sequence owner-proof) :snapshot
(gethash owner-id (plist-get base :layout-snapshots))))
(ebox-surface--owner-patch-candidate
(current-buffer) candidate owner-id
(plist-get owner-proof :allocated-width)
@ -4288,7 +4452,7 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(plist-get owner-proof :changed-keys)
(plist-get owner-proof :allocation-closure-p)
(plist-get owner-proof :retain-external-owner-suffix-p)
base))
base)))
(when cached-candidate
(setq cached-lines (copy-sequence lines))
(cl-mapc
@ -4309,6 +4473,14 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(plist-get previous-state :node-table)))
(visible (ebox--region-ids-visible-in-buffer-p
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
(if visible
(ebox-surface--buffer-span-patch-output buffer previous-state state)
@ -4337,6 +4509,10 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
bounds)
(plist-put private :region-line-bounds-index copy)))
(when rendered
;; Auto-width scroll chrome can measure its candidate children
;; while shaping a cached line. Those children belong to this
;; input generation, even though BUFFER still holds the old one.
(let ((ebox--render-source-index (plist-get state :source-index)))
(dolist (change changes)
(let* ((number (car change))
(old (nth number rendered))
@ -4344,7 +4520,7 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(plist-get private :box) (list (nth 2 change))
region number))))
(setcar (nthcdr number rendered) new)
(push (list number old new) rendered-changes))))
(push (list number old new) rendered-changes)))))
(plist-put private :content-lines cached-lines)
(plist-put private :region-line-span-index
(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
(plist-get private :rendered-region-line-span-index)
rendered-changes))
;; The validated owner preserves line and property topology;
;; line bounds/membership stay valid, character hints do not.
(cl-remf private :region-line-span-hints)
(if nested-scroll-p
;; A viewport reveals different child roles. Rebuild the
;; 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)))
(cl-remf state :scroll-content-proof))))
output)))
@ -4378,6 +4564,9 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(mapcar
(lambda (proof)
(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)
(ebox-surface--layer-patch-candidate
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 :allocation-closure-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)
(list
(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))))
(when (and candidates (cl-every #'identity candidates)
(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
(save-restriction
(widen)
@ -4889,14 +5090,86 @@ causes the caller to use the ordinary full projection."
(mapcar (lambda (fragment) (plist-get fragment :text))
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)
"Return a staged visible scroll window without rerunning layout when safe.
Chrome-free top-aligned root scroll boxes already store fully propertized
rendered lines. Join only the visible slice and add the scroll marker; any
box with wrapper chrome falls back to the retained wrapper renderer.
When COMPLETE-LINES-ONLY-P is non-nil, return only proven complete cached
physical lines, with private pixel-normalized copies where needed. Decline
wrapper rendering so a caller may keep its ordinary fractional-origin guard."
physical lines or a complete cached root wrapper, with private pixel-normalized
copies where needed. Detached owners keep their fractional-origin guard."
(let* ((region-id (plist-get state :scroll-patch-region-id))
(scroll-table (plist-get state :scroll-state-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)
output))
output)
(unless complete-lines-only-p
(let ((ebox--scroll-window-cached-state scroll-state)
(let ((root-wrapper-p
(and (or (plist-get state :layered-p) (not chrome-free-p))
(ebox-layer-cached-root-scroll-p root scroll-state))))
;; Ordinary chrome-free roots first rebuild physical row metadata
;; 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
(plist-get scroll-state :content-lines-complete-p)))
(ebox-surface--render-candidate-node state root))))))))
(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)
"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
context leaf state node-objects region-objects attach-p
(lambda (visit)
(let ((offset 0))
(dolist (line lines)
(let ((template
(or (ebox-surface--scroll-line-fragment-template line)
(error "Ebox retained scroll line lost fragment ownership"))))
(dolist (fragment template)
(unless (ebox-surface--map-scroll-fragments
lines
(lambda (fragment offset _line-index)
(funcall visit
(plist-get fragment :role-ids)
(+ offset (plist-get fragment :start))
(+ offset (plist-get fragment :end))
(plist-get fragment :layout-region-ids)))
(setq offset (+ offset (length line) 1))))))))
(plist-get fragment :layout-region-ids))))
(error "Ebox retained scroll line lost fragment ownership")))))
(defun ebox-surface--native-owned-ranges
(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
mixed-owner-reflow))
(not formatting-reflow-p)
(not (plist-get state :layer-recompose-p))
(plist-get state :previous-surface-owned-ranges)
(plist-get state :content-coordinate-patches)
(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
:text source :capability 'content)))
(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
(and (null previous-state)
(null projection-kind)
@ -6355,7 +6654,7 @@ published runtime and must be consumed without clearing those shared nodes."
(setq fast nil))
fast)))
(stylesheet-active-p
(if scroll-fast-p
(if scroll-input-stable-p
(plist-get previous-state :cascade-active-p)
(ebox-style-cascade-active-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)))
dirty-set)))))
(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)
(ebox-surface--inline-inheritance-required-p
root source-index)))
(author-style-pending-p
(> (ebox-tree-author-style-count root) 0))
(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)
(plist-get previous-state :cascade-required-p)
(or stylesheet-active-p
inline-inheritance-required-p)))
(style-required-p
(if scroll-fast-p
(if scroll-input-stable-p
cascade-required-p
(or cascade-required-p
author-style-pending-p
@ -6493,7 +6792,7 @@ published runtime and must be consumed without clearing those shared nodes."
(and
signals
(cond
((and scroll-fast-p
((and scroll-input-stable-p
(plist-get previous-state
:viewport-dependent-node-ids-ready)
(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--scroll-patch-output
(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
;; complete candidate projection.
;; Preserve its ordinary renderer
;; fallback without recascading the
;; retained source. The raw-output
;; 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))))
('formatting-context-reflow
(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,
;; fragments or paint layers. Rebuild from the original
;; 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
;; map, including newly registered keys in this context.
(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 :computed-participation-validated-p)
(cl-remf state :candidate-subject-table)
(cl-remf state :nested-scroll-patch-proof)
result)))))))
(defun ebox-surface-producer

240
ebox.el
View File

@ -91,6 +91,44 @@
buffer 'batch
(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--set-buffer-update-report
"ebox-incremental" (buffer report))
@ -1089,6 +1127,7 @@ content back to their own source model."
:region-line-span-index-deferred
:rendered-region-line-span-index
:rendered-region-line-span-index-deferred
:rendered-content-line-cells
:region-line-span-hints
:lazy-scroll-prefix-dirty
:lazy-scroll-window-refresh-required
@ -1268,6 +1307,33 @@ remain transaction-local."
(when had-state
(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)
"Return STATE's current rendered visible slice, including text properties."
(let* ((content-height (plist-get state :content-height))
@ -1282,10 +1348,16 @@ remain transaction-local."
(list offset native-lines))
((and (integerp content-height) (>= content-height 0))
(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)))
(end (min (+ offset content-height) (length lines))))
(list offset (seq-subseq lines offset end))))))))
(end (min (+ offset content-height) count)))
(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)
"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
(state content-lines &optional rendered-lines)
"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)))
(setq state
(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
region-id state required-lines t))))
(defun ebox--surface-scroll-runtime (buffer region-id)
"Return isolated scroll candidate runtime for BUFFER and REGION-ID."
(defun ebox--surface-scroll-cached-intent-p (scroll delta)
"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))
(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
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
;; tree. Copy only that owner path when it is the published root;
;; nested scroll owners retain the old conservative isolated-copy
;; path until their ancestor publication contract is proven. The
;; tree. A nested owner additionally keeps a retained allocation
;; certificate; its cached wrapper never renders shared descendants. The
;; previous implementation copied and indexed the complete gallery
;; for every wheel event, which made a 512-node grid visibly stall.
(path-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
buffer region-id)))
buffer region-id (not cached-intent-p))))
(root (or path-root
(ebox-tree-copy-node-structure old-root)))
;; 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)))
(candidate-box (ebox--root-region-box root region-id))
(_ (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
(if cached-intent-p
(list :region-id-set (plist-get old-state :region-id-set)
:region-box-table region-box-table))
: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
(let ((ebox--render-string-pixel-width-cache
(unless cached-intent-p (make-hash-table :test 'equal)))
(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))
(old-scroll-state (ebox--scroll-get-state region-id))
buffer old-state index region-box-table)))
(candidate-scroll-state (gethash region-id scroll-table))
(state (copy-sequence old-state)))
(dolist (key '(:render-content-prefix :materialize-content-lines))
(when (plist-member old-scroll-state key)
;; Even a cache hit publishes a new Box. Keep its rebound continuations:
;; 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
(plist-put candidate-scroll-state key
(plist-get old-scroll-state key)))))
(ebox--scroll-state-set-lines
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)
(setq state (ebox--render-state-install-index state index))
(plist-put state :root-node root)
(plist-put state :scroll-state-table scroll-table)
(unless cached-intent-p
(ebox-incremental--isolate-candidate-caches state))
(unless (gethash region-id scroll-table)
(error "Ebox scroll region has no candidate state: %S" region-id))
(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)
"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"))
(let* ((staged-scroll-state (copy-sequence state))
(scroll-patch-fast-p
(and (ebox--scroll-state-retained-window-ready-p
staged-scroll-state)
t))
(or (and (ebox--scroll-state-retained-window-ready-p
staged-scroll-state) t)
(and (plist-get runtime :nested-scroll-patch-proof) t)))
(commit-input
(let ((ebox--scroll-global-state table))
(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)))
(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
(plist-put
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))
(state (gethash region-id table)))
(dolist (key '(:content-lines :rendered-content-lines
:rendered-content-line-cells
:content-lines-complete-p :render-content-prefix
:materialize-content-lines :content-region-id-set
: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
(buffer region-id delta 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))
(candidate-state (plist-get runtime :state))
(transition
(let ((ebox--scroll-global-state table)
(ebox--region-box-table (plist-get candidate-state :region-box-table))
(ebox--scroll-idle-prefetch-timers
(make-hash-table :test 'equal))
(ebox--smooth-scroll-state-table

View File

@ -455,7 +455,7 @@
(should (zerop root-renders)))))))
(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))
(ert-info ((format "inherited text exclusion: %S" change))
(with-temp-buffer
@ -492,7 +492,12 @@
(when (eq change 'identity)
(should-not (ebox--host-ref-node (current-buffer) '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 ()
"Late publication rejection retains text, metadata and one old generation."
@ -1833,7 +1838,7 @@
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(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*"))
(old-root
(ebox-test-column
@ -1866,7 +1871,8 @@
(with-current-buffer buffer (buffer-string))))
(should (eq (plist-get report :strategy) '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)
(kill-buffer buffer)))))
@ -2314,6 +2320,8 @@ remain retained identities."
;; publishes one scoped transaction, with one exact text operation
;; per changed slot instead of replacing the unchanged gap.
(should (= 2 (plist-get report :tp-text-operations)))
(with-current-buffer buffer
(ebox-commit-test--assert-full-render-equivalent))
(should (string-match-p "L"
(ebox-commit-test--buffer-string buffer)))
(setq fresh
@ -2383,10 +2391,61 @@ remain retained identities."
(should (memq (plist-get report :projection-kind)
'(span-patch owner-scoped)))
(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)
(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 ()
"A structural candidate should publish exact author selector-type counts."
(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)))
(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))
(with-temp-buffer
(ebox-render-to-buffer

View File

@ -144,6 +144,26 @@
(should (equal-including-properties before source))
(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 ()
"Shared string and image replacements are kept whole, even across faces."
(ebox-composite-test--with-metrics

View File

@ -367,6 +367,24 @@
"Return first content face in STRING."
(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)
"Run BODY and return the number of full TP surface updates it triggered."
(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)
'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 ()
"Empty and non-empty flushes should update only the target buffer report."
(ebox-test--reset-runtime-state)
@ -7797,17 +8010,13 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox-test--with-scroll-producer-counts (prefix-count)
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(initial-lines (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height))
(boundary-offset (- initial-lines content-height)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state)
(with-current-buffer buffer
(should (= boundary-offset
(ebox--scroll-region-by root-id
@ -7827,7 +8036,7 @@ line compositor's complete property output, including empty lines and chrome."
(plist-get (ebox--buffer-render-state buffer)
:surface-fragments)
:scroll-lines))))))
(should (= prefix-count 1))))
(should (= prefix-count 1)))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(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*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox-test--with-scroll-producer-counts (prefix-count)
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(initial-lines (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height))
(boundary-offset (- initial-lines content-height)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state)
(with-current-buffer buffer
(should (= boundary-offset
(ebox--scroll-region-by root-id
boundary-offset)))
(should (= 4 (ebox--scroll-region-by root-id 4 4)))))
(should (= prefix-count 1))))
(should (= prefix-count 1)))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer))
@ -9238,21 +9443,9 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(ebox-test--with-scroll-producer-counts (prefix-count materialize-count)
(let ((state (ebox--scroll-get-state root-id)))
(should (= (length (plist-get state :content-lines)) 3)))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (= prefix-count 1))
@ -9263,7 +9456,7 @@ line compositor's complete property output, including empty lines and chrome."
(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))))
(should-not (plist-get entry :waiting-prefetch)))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
@ -9301,21 +9494,9 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(ebox-test--with-scroll-producer-counts (prefix-count materialize-count)
(let ((state (ebox--scroll-get-state root-id)))
(should (= (length (plist-get state :content-lines)) 3)))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16))
(ebox--smooth-scroll-tick root-id))
@ -9326,7 +9507,7 @@ line compositor's complete property output, including empty lines and chrome."
1))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))))
(should-not (plist-get entry :waiting-prefetch)))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
@ -9365,16 +9546,9 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (state region-id required-lines)
(cl-incf prefix-count)
(setq requested-lines required-lines)
(funcall render-prefix state region-id required-lines)))
(puthash root-id state ebox--scroll-global-state))
(ebox-test--with-scroll-producer-counts (prefix-count nil requested-lines)
(let ((state (ebox--scroll-get-state root-id)))
(should (= (length (plist-get state :content-lines)) 3)))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 1000)))
(should (= prefix-count 1))
@ -9384,7 +9558,7 @@ line compositor's complete property output, including empty lines and chrome."
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))))
(should-not (plist-get entry :waiting-prefetch)))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
@ -9421,14 +9595,8 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(ebox-test--with-scroll-producer-counts (prefix-count)
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (gethash root-id ebox--smooth-scroll-state-table))
@ -9439,7 +9607,7 @@ line compositor's complete property output, including empty lines and chrome."
(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))))
(should (> prefix-count prefix-after-start)))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
@ -9476,14 +9644,8 @@ line compositor's complete property output, including empty lines and chrome."
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(ebox-test--with-scroll-producer-counts (prefix-count)
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (> prefix-count 0))
@ -9496,7 +9658,7 @@ line compositor's complete property output, including empty lines and chrome."
(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)))))
(should (timerp (plist-get entry :timer))))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
@ -14224,4 +14386,105 @@ face patch must match that exactly (issue014)."
(should-not (gethash 999 actual))
(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

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)))
lines (seq-take raw 3))))))))
(ert-deftest ebox-css-layout-fractional-scroll-keeps-wrapper-fallbacks ()
"Nested or chrome-bearing fractional scrolls retain full-line projection."
(ert-deftest ebox-css-layout-fractional-scroll-respects-wrapper-boundaries ()
"Root chrome normalizes complete rows; nested fractional origins fall back."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 3))
@ -292,7 +292,7 @@ MIXED starts with three integral rows before the fractional cached rows."
(cl-incf calls)
(apply original args))))
(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))
(ebox-css-layout-test--display
(ebox-render

View File

@ -330,6 +330,138 @@
(kill-buffer target)
(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 ()
"IDs resolve locally and opaque handles retain their owning surface checks."
(let ((first (generate-new-buffer " *ebox-id-first*"))

View File

@ -42,6 +42,54 @@
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
: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 ()
"Each layer declaration reaches the mounted runtime through a region update."
(dolist (properties '((:position relative) (:left (ch 2)) (:top (lh 1))
@ -181,6 +229,60 @@
(should (equal (get-text-property position 'help-echo) "lower-new")))
(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 ()
"A detached host retains enclosing paint and interaction contributions."
(ebox-layer-publication-test--with-buffer
@ -218,6 +320,45 @@
(should (string-match-p "LOWER002" (buffer-string)))
(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 ()
"Updates outside a root portal's local host cannot paint over the portal."
(ebox-layer-publication-test--with-buffer
@ -319,5 +460,186 @@
(should (string-match-p "Before" (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)
;;; 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-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)
;;; ebox-layer-tests.el ends here

View File

@ -94,6 +94,7 @@
((string-match-p "canonical" name) 'ebox-canonical)
((string-match-p "render-burst" name) 'ebox-buffer-backend)
((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)
'ebox-measure)
((string-match-p "style" name) 'ebox-style)
@ -153,6 +154,7 @@
ebox-incremental--allocated-slot-proof-cache
ebox-incremental--candidate-path-copy-origin-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--closed-inheritance-cache ebox-style--computed-snapshot-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